From 234a9608b6eca0c964cb1900a48023c6c397952a Mon Sep 17 00:00:00 2001 From: Michael Plump Date: Tue, 25 Jun 2019 15:16:48 -0400 Subject: [PATCH] feat(google): add a StatefullyUpdateBootImage GCE operation (#3815) * refactor(google): add a FakeComputeBatchRequest * feat(google): add a StatefullyUpdateBootImage GCE operation * style(google): Add missing copyright headers. * style(google/tests): remove unnecessary junit version --- .../orchestration/AtomicOperations.java | 1 + clouddriver-google/clouddriver-google.gradle | 3 + .../AbstractGoogleServerGroupManagers.java | 7 + .../google/compute/ComputeBatchRequest.java | 194 +------------ .../compute/ComputeBatchRequestImpl.java | 220 +++++++++++++++ .../compute/GoogleComputeApiFactory.java | 2 +- .../compute/GoogleServerGroupManagers.java | 2 + .../RegionGoogleServerGroupManagers.java | 5 + .../ZoneGoogleServerGroupManagers.java | 5 + .../clouddriver/google/deploy/GCEUtil.groovy | 4 + ...ullyUpdateBootImageOperationConverter.java | 64 +++++ .../StatefullyUpdateBootImageDescription.java | 32 +++ ...GoogleResourceIllegalStateException.groovy | 22 +- ...tefullyUpdateBootImageAtomicOperation.java | 242 ++++++++++++++++ ...lyUpdateBootImageDescriptionValidator.java | 36 +++ ....java => ComputeBatchRequestImplTest.java} | 46 +-- .../compute/FakeComputeBatchRequest.java | 59 ++++ .../FakeGoogleComputeOperationRequest.java | 4 + .../compute/FakeGoogleComputeRequest.java | 14 +- ...StatefulDiskAtomicOperationUnitSpec.groovy | 2 +- ...llyUpdateBootImageAtomicOperationTest.java | 262 ++++++++++++++++++ 21 files changed, 1007 insertions(+), 219 deletions(-) create mode 100644 clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImpl.java create mode 100644 clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/converters/StatefullyUpdateBootImageOperationConverter.java create mode 100644 clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/description/StatefullyUpdateBootImageDescription.java create mode 100644 clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperation.java create mode 100644 clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/validators/StatefullyUpdateBootImageDescriptionValidator.java rename clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/{ComputeBatchRequestTest.java => ComputeBatchRequestImplTest.java} (92%) create mode 100644 clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeComputeBatchRequest.java create mode 100644 clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperationTest.java diff --git a/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java b/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java index f643dcaef1d..2dd4f4896c6 100644 --- a/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java +++ b/clouddriver-core/src/main/groovy/com/netflix/spinnaker/clouddriver/orchestration/AtomicOperations.java @@ -35,6 +35,7 @@ public final class AtomicOperations { public static final String START_SERVER_GROUP = "startServerGroup"; public static final String STOP_SERVER_GROUP = "stopServerGroup"; public static final String SET_STATEFUL_DISK = "setStatefulDisk"; + public static final String STATEFULLY_UPDATE_BOOT_IMAGE = "statefullyUpdateBootImage"; public static final String UPSERT_DISRUPTION_BUDGET = "upsertDisruptionBudget"; public static final String UPDATE_JOB_PROCESSES = "updateJobProcesses"; diff --git a/clouddriver-google/clouddriver-google.gradle b/clouddriver-google/clouddriver-google.gradle index aa799f8128a..38c10577aff 100644 --- a/clouddriver-google/clouddriver-google.gradle +++ b/clouddriver-google/clouddriver-google.gradle @@ -27,7 +27,10 @@ dependencies { testImplementation "cglib:cglib-nodep" testImplementation "org.assertj:assertj-core" + testImplementation "org.junit.jupiter:junit-jupiter-api" + testImplementation "org.junit.platform:junit-platform-runner" testImplementation "org.mockito:mockito-core" + testImplementation "org.mockito:mockito-junit-jupiter" testImplementation "org.objenesis:objenesis" testImplementation "org.spockframework:spock-core" testImplementation "org.spockframework:spock-spring" diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/AbstractGoogleServerGroupManagers.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/AbstractGoogleServerGroupManagers.java index 2fe5f76ea94..2289032a782 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/AbstractGoogleServerGroupManagers.java +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/AbstractGoogleServerGroupManagers.java @@ -69,6 +69,13 @@ public GoogleComputeRequest, InstanceGroupM abstract ComputeRequest performGet() throws IOException; + @Override + public GoogleComputeOperationRequest patch(InstanceGroupManager content) throws IOException { + return wrapOperationRequest(performPatch(content), "patch"); + } + + abstract ComputeRequest performPatch(InstanceGroupManager content) throws IOException; + @Override public GoogleComputeOperationRequest> update( InstanceGroupManager content) throws IOException { diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequest.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequest.java index 4c71dd3779c..a76df68c7c5 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequest.java +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequest.java @@ -16,200 +16,14 @@ package com.netflix.spinnaker.clouddriver.google.compute; -import static com.google.common.collect.Lists.partition; - -import com.google.api.client.googleapis.batch.BatchRequest; import com.google.api.client.googleapis.batch.json.JsonBatchCallback; -import com.google.api.client.util.Throwables; -import com.google.api.services.compute.Compute; import com.google.api.services.compute.ComputeRequest; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableMap; -import com.google.common.util.concurrent.AbstractFuture; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.netflix.spectator.api.Registry; import java.io.IOException; -import java.io.InterruptedIOException; -import java.io.UncheckedIOException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.AllArgsConstructor; -import lombok.Value; -import org.apache.http.client.HttpResponseException; - -public class ComputeBatchRequest, ResponseT> { - - // Platform-specified max to not overwhelm batch backends. - @VisibleForTesting static final int MAX_BATCH_SIZE = 100; - private static final Duration CONNECT_TIMEOUT = Duration.ofMinutes(2); - private static final Duration READ_TIMEOUT = Duration.ofMinutes(2); - - private final Compute compute; - private final Registry registry; - private final String userAgent; - private final ListeningExecutorService executor; - private final List> queuedRequests; - - ComputeBatchRequest( - Compute compute, Registry registry, String userAgent, ListeningExecutorService executor) { - this.compute = compute; - this.registry = registry; - this.userAgent = userAgent; - this.executor = executor; - this.queuedRequests = new ArrayList<>(); - } - - public void queue( - GoogleComputeRequest request, JsonBatchCallback callback) { - queuedRequests.add(new QueuedRequest<>(request.getRequest(), callback)); - } - - public void execute(String batchContext) throws IOException { - if (queuedRequests.size() == 0) { - return; - } - - List>> requestPartitions = - partition(queuedRequests, MAX_BATCH_SIZE); - List queuedBatches = createBatchRequests(requestPartitions); - - String statusCode = "500"; - String success = "false"; - long start = registry.clock().monotonicTime(); - try { - executeBatches(queuedBatches); - success = "true"; - statusCode = "200"; - } catch (HttpResponseException e) { - statusCode = Integer.toString(e.getStatusCode()); - throw e; - } finally { - long nanos = registry.clock().monotonicTime() - start; - String status = statusCode.charAt(0) + "xx"; - Map tags = - ImmutableMap.of( - "context", batchContext, - "success", success, - "status", status, - "statusCode", statusCode); - registry - .timer(registry.createId("google.batchExecute", tags)) - .record(Duration.ofNanos(nanos)); - registry - .counter(registry.createId("google.batchSize", tags)) - .increment(queuedRequests.size()); - } - } - - private void executeBatches(List queuedBatches) throws IOException { - if (queuedBatches.size() == 1) { - queuedBatches.get(0).execute(); - return; - } - - List> futures = new ArrayList<>(); - for (BatchRequest batchRequest : queuedBatches) { - ListenableFuture submit = - executor.submit( - () -> { - batchRequest.execute(); - return null; - }); - futures.add(submit); - } - - try { - new FailFastFuture(futures, executor).get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new InterruptedIOException(); - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - Throwables.propagateIfPossible(cause, IOException.class); - throw new RuntimeException(cause); - } - } - - private List createBatchRequests( - List>> requestPartitions) throws IOException { - - List queuedBatches = new ArrayList<>(); - - try { - requestPartitions.forEach( - partition -> { - BatchRequest batch = newBatch(); - partition.forEach( - qr -> wrapIOException(() -> qr.getRequest().queue(batch, qr.getCallback()))); - queuedBatches.add(batch); - }); - return queuedBatches; - } catch (UncheckedIOException e) { - throw e.getCause(); - } - } - - private BatchRequest newBatch() { - return compute.batch( - request -> { - request.getHeaders().setUserAgent(userAgent); - request.setConnectTimeout((int) CONNECT_TIMEOUT.toMillis()); - request.setReadTimeout((int) READ_TIMEOUT.toMillis()); - }); - } - - @FunctionalInterface - private interface IoExceptionRunnable { - void run() throws IOException; - } - - private static void wrapIOException(IoExceptionRunnable runnable) { - try { - runnable.run(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Value - @AllArgsConstructor - private static class QueuedRequest, ResponseT> { - private RequestT request; - private JsonBatchCallback callback; - } - - private static class FailFastFuture extends AbstractFuture { - private final AtomicInteger remainingFutures; +public interface ComputeBatchRequest, ResponseT> { - FailFastFuture(List> futures, ExecutorService executor) { - remainingFutures = new AtomicInteger(futures.size()); - for (ListenableFuture future : futures) { - Futures.addCallback( - future, - new FutureCallback() { - @Override - public void onSuccess(Object result) { - if (remainingFutures.decrementAndGet() == 0) { - set(null); - } - } + void queue( + GoogleComputeRequest request, JsonBatchCallback callback); - @Override - public void onFailure(Throwable t) { - setException(t); - } - }, - executor); - } - } - } + void execute(String batchContext) throws IOException; } diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImpl.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImpl.java new file mode 100644 index 00000000000..3cc1561d418 --- /dev/null +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImpl.java @@ -0,0 +1,220 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.compute; + +import static com.google.common.collect.Lists.partition; + +import com.google.api.client.googleapis.batch.BatchRequest; +import com.google.api.client.googleapis.batch.json.JsonBatchCallback; +import com.google.api.client.util.Throwables; +import com.google.api.services.compute.Compute; +import com.google.api.services.compute.ComputeRequest; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.AbstractFuture; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.netflix.spectator.api.Registry; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import lombok.Value; +import org.apache.http.client.HttpResponseException; + +final class ComputeBatchRequestImpl, ResponseT> + implements ComputeBatchRequest { + + // Platform-specified max to not overwhelm batch backends. + @VisibleForTesting static final int MAX_BATCH_SIZE = 100; + private static final Duration CONNECT_TIMEOUT = Duration.ofMinutes(2); + private static final Duration READ_TIMEOUT = Duration.ofMinutes(2); + + private final Compute compute; + private final Registry registry; + private final String userAgent; + private final ListeningExecutorService executor; + private final List> queuedRequests; + + ComputeBatchRequestImpl( + Compute compute, Registry registry, String userAgent, ListeningExecutorService executor) { + this.compute = compute; + this.registry = registry; + this.userAgent = userAgent; + this.executor = executor; + this.queuedRequests = new ArrayList<>(); + } + + @Override + public void queue( + GoogleComputeRequest request, JsonBatchCallback callback) { + queuedRequests.add(new QueuedRequest<>(request.getRequest(), callback)); + } + + @Override + public void execute(String batchContext) throws IOException { + if (queuedRequests.size() == 0) { + return; + } + + List>> requestPartitions = + partition(queuedRequests, MAX_BATCH_SIZE); + List queuedBatches = createBatchRequests(requestPartitions); + + String statusCode = "500"; + String success = "false"; + long start = registry.clock().monotonicTime(); + try { + executeBatches(queuedBatches); + success = "true"; + statusCode = "200"; + } catch (HttpResponseException e) { + statusCode = Integer.toString(e.getStatusCode()); + throw e; + } finally { + long nanos = registry.clock().monotonicTime() - start; + String status = statusCode.charAt(0) + "xx"; + Map tags = + ImmutableMap.of( + "context", batchContext, + "success", success, + "status", status, + "statusCode", statusCode); + registry + .timer(registry.createId("google.batchExecute", tags)) + .record(Duration.ofNanos(nanos)); + registry + .counter(registry.createId("google.batchSize", tags)) + .increment(queuedRequests.size()); + } + } + + private void executeBatches(List queuedBatches) throws IOException { + if (queuedBatches.size() == 1) { + queuedBatches.get(0).execute(); + return; + } + + List> futures = + queuedBatches.stream() + .map( + batchRequest -> + executor.submit( + (Callable) + () -> { + batchRequest.execute(); + return null; + })) + .collect(Collectors.toList()); + try { + new FailFastFuture(futures, executor).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException(); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + Throwables.propagateIfPossible(cause, IOException.class); + throw new RuntimeException(cause); + } + } + + private List createBatchRequests( + List>> requestPartitions) throws IOException { + + List queuedBatches = new ArrayList<>(); + + try { + requestPartitions.forEach( + partition -> { + BatchRequest batch = newBatch(); + partition.forEach( + qr -> wrapIOException(() -> qr.getRequest().queue(batch, qr.getCallback()))); + queuedBatches.add(batch); + }); + return queuedBatches; + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + private BatchRequest newBatch() { + return compute.batch( + request -> { + request.getHeaders().setUserAgent(userAgent); + request.setConnectTimeout((int) CONNECT_TIMEOUT.toMillis()); + request.setReadTimeout((int) READ_TIMEOUT.toMillis()); + }); + } + + @FunctionalInterface + private interface IoExceptionRunnable { + void run() throws IOException; + } + + private static void wrapIOException(IoExceptionRunnable runnable) { + try { + runnable.run(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Value + @AllArgsConstructor + private static class QueuedRequest, ResponseT> { + private RequestT request; + private JsonBatchCallback callback; + } + + private static class FailFastFuture extends AbstractFuture { + + private final AtomicInteger remainingFutures; + + FailFastFuture(List> futures, ExecutorService executor) { + remainingFutures = new AtomicInteger(futures.size()); + for (ListenableFuture future : futures) { + Futures.addCallback( + future, + new FutureCallback() { + @Override + public void onSuccess(Object result) { + if (remainingFutures.decrementAndGet() == 0) { + set(null); + } + } + + @Override + public void onFailure(Throwable t) { + setException(t); + } + }, + executor); + } + } + } +} diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleComputeApiFactory.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleComputeApiFactory.java index 7fa911ab162..096fa724cfb 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleComputeApiFactory.java +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleComputeApiFactory.java @@ -67,7 +67,7 @@ public InstanceTemplates createInstanceTemplates(GoogleNamedAccountCredentials c public , ResponseT> ComputeBatchRequest createBatchRequest( GoogleNamedAccountCredentials credentials) { - return new ComputeBatchRequest<>( + return new ComputeBatchRequestImpl<>( credentials.getCompute(), registry, clouddriverUserAgentApplicationName, batchExecutor); } } diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleServerGroupManagers.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleServerGroupManagers.java index db465b4e9d6..2ff49dcc2a3 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleServerGroupManagers.java +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/GoogleServerGroupManagers.java @@ -39,6 +39,8 @@ GoogleComputeOperationRequest> abandonInstances(List, InstanceGroupManager> get() throws IOException; + GoogleComputeOperationRequest patch(InstanceGroupManager content) throws IOException; + GoogleComputeOperationRequest> update(InstanceGroupManager content) throws IOException; } diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/RegionGoogleServerGroupManagers.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/RegionGoogleServerGroupManagers.java index 187976f91f9..f6cb664908d 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/RegionGoogleServerGroupManagers.java +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/RegionGoogleServerGroupManagers.java @@ -67,6 +67,11 @@ ComputeRequest performGet() throws IOException { return managers.get(getProject(), region, getInstanceGroupName()); } + @Override + ComputeRequest performPatch(InstanceGroupManager content) throws IOException { + return managers.patch(getProject(), region, getInstanceGroupName(), content); + } + @Override ComputeRequest performUpdate(InstanceGroupManager content) throws IOException { return managers.update(getProject(), region, getInstanceGroupName(), content); diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ZoneGoogleServerGroupManagers.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ZoneGoogleServerGroupManagers.java index 2a1b35bf604..0e049feb897 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ZoneGoogleServerGroupManagers.java +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/compute/ZoneGoogleServerGroupManagers.java @@ -67,6 +67,11 @@ ComputeRequest performGet() throws IOException { return managers.get(getProject(), zone, getInstanceGroupName()); } + @Override + ComputeRequest performPatch(InstanceGroupManager content) throws IOException { + return managers.patch(getProject(), zone, getInstanceGroupName(), content); + } + @Override ComputeRequest performUpdate(InstanceGroupManager content) throws IOException { return managers.update(getProject(), zone, getInstanceGroupName(), content); diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/GCEUtil.groovy b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/GCEUtil.groovy index 948c72ebda1..0fd3010ec66 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/GCEUtil.groovy +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/GCEUtil.groovy @@ -742,6 +742,10 @@ class GCEUtil { return GCE_API_PREFIX + "$projectName/global/healthChecks/$healthCheckName" } + static String buildInstanceTemplateUrl(String projectName, String templateName) { + return GCE_API_PREFIX + "$projectName/global/instanceTemplates/$templateName" + } + static String buildBackendServiceUrl(String projectName, String backendServiceName) { return GCE_API_PREFIX + "$projectName/global/backendServices/$backendServiceName" } diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/converters/StatefullyUpdateBootImageOperationConverter.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/converters/StatefullyUpdateBootImageOperationConverter.java new file mode 100644 index 00000000000..0fd3e57e62b --- /dev/null +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/converters/StatefullyUpdateBootImageOperationConverter.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.deploy.converters; + +import com.netflix.spinnaker.clouddriver.google.GoogleOperation; +import com.netflix.spinnaker.clouddriver.google.compute.GoogleComputeApiFactory; +import com.netflix.spinnaker.clouddriver.google.config.GoogleConfigurationProperties; +import com.netflix.spinnaker.clouddriver.google.deploy.description.StatefullyUpdateBootImageDescription; +import com.netflix.spinnaker.clouddriver.google.deploy.ops.StatefullyUpdateBootImageAtomicOperation; +import com.netflix.spinnaker.clouddriver.google.provider.view.GoogleClusterProvider; +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations; +import com.netflix.spinnaker.clouddriver.security.AbstractAtomicOperationsCredentialsSupport; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@GoogleOperation(AtomicOperations.STATEFULLY_UPDATE_BOOT_IMAGE) +@Component +public class StatefullyUpdateBootImageOperationConverter + extends AbstractAtomicOperationsCredentialsSupport { + + private final GoogleClusterProvider clusterProvider; + private final GoogleComputeApiFactory computeApiFactory; + private final GoogleConfigurationProperties googleConfigurationProperties; + + @Autowired + public StatefullyUpdateBootImageOperationConverter( + GoogleClusterProvider clusterProvider, + GoogleComputeApiFactory computeApiFactory, + GoogleConfigurationProperties googleConfigurationProperties) { + this.clusterProvider = clusterProvider; + this.computeApiFactory = computeApiFactory; + this.googleConfigurationProperties = googleConfigurationProperties; + } + + @Override + public StatefullyUpdateBootImageAtomicOperation convertOperation(Map input) { + return new StatefullyUpdateBootImageAtomicOperation( + clusterProvider, + computeApiFactory, + googleConfigurationProperties, + convertDescription(input)); + } + + @Override + public StatefullyUpdateBootImageDescription convertDescription(Map input) { + return GoogleAtomicOperationConverterHelper.convertDescription( + input, this, StatefullyUpdateBootImageDescription.class); + } +} diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/description/StatefullyUpdateBootImageDescription.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/description/StatefullyUpdateBootImageDescription.java new file mode 100644 index 00000000000..62a72a25eb6 --- /dev/null +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/description/StatefullyUpdateBootImageDescription.java @@ -0,0 +1,32 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.deploy.description; + +import com.netflix.spinnaker.clouddriver.google.security.GoogleNamedAccountCredentials; +import com.netflix.spinnaker.clouddriver.security.resources.CredentialsNameable; +import com.netflix.spinnaker.clouddriver.security.resources.ServerGroupNameable; +import lombok.Data; + +@Data +public class StatefullyUpdateBootImageDescription + implements CredentialsNameable, ServerGroupNameable { + + private GoogleNamedAccountCredentials credentials; + private String serverGroupName; + private String region; + private String bootImage; +} diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/exception/GoogleResourceIllegalStateException.groovy b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/exception/GoogleResourceIllegalStateException.groovy index 26cb8b92830..1bd12404deb 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/exception/GoogleResourceIllegalStateException.groovy +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/exception/GoogleResourceIllegalStateException.groovy @@ -16,8 +16,28 @@ package com.netflix.spinnaker.clouddriver.google.deploy.exception +import com.google.common.base.Strings +import groovy.transform.CompileStatic import groovy.transform.InheritConstructors @InheritConstructors +@CompileStatic +class GoogleResourceIllegalStateException extends GoogleOperationException { -class GoogleResourceIllegalStateException extends GoogleOperationException {} + // @InheritConstructors apparently doesn't work with Java callers + GoogleResourceIllegalStateException(String message) { + super(message) + } + + static checkResourceState(boolean expression, Object message) { + if (!expression) { + throw new GoogleResourceIllegalStateException(String.valueOf(message)); + } + } + + static checkResourceState(boolean expression, String errorMessageTemplate, Object... errorMessageArgs) { + if (!expression) { + throw new GoogleResourceIllegalStateException(Strings.lenientFormat(errorMessageTemplate, errorMessageArgs)); + } + } +} diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperation.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperation.java new file mode 100644 index 00000000000..f0e62f3f5d3 --- /dev/null +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperation.java @@ -0,0 +1,242 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.deploy.ops; + +import static com.google.common.base.Preconditions.checkState; +import static com.netflix.spinnaker.clouddriver.google.deploy.exception.GoogleResourceIllegalStateException.checkResourceState; +import static java.util.stream.Collectors.toList; + +import com.google.api.client.googleapis.batch.json.JsonBatchCallback; +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.util.Throwables; +import com.google.api.services.compute.Compute; +import com.google.api.services.compute.Compute.InstanceTemplates.Get; +import com.google.api.services.compute.model.AttachedDisk; +import com.google.api.services.compute.model.Image; +import com.google.api.services.compute.model.ImageList; +import com.google.api.services.compute.model.InstanceGroupManager; +import com.google.api.services.compute.model.InstanceGroupManagerUpdatePolicy; +import com.google.api.services.compute.model.InstanceTemplate; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.SettableFuture; +import com.netflix.spinnaker.clouddriver.data.task.Task; +import com.netflix.spinnaker.clouddriver.data.task.TaskRepository; +import com.netflix.spinnaker.clouddriver.google.compute.ComputeBatchRequest; +import com.netflix.spinnaker.clouddriver.google.compute.GoogleComputeApiFactory; +import com.netflix.spinnaker.clouddriver.google.compute.GoogleComputeRequest; +import com.netflix.spinnaker.clouddriver.google.compute.GoogleServerGroupManagers; +import com.netflix.spinnaker.clouddriver.google.compute.Images; +import com.netflix.spinnaker.clouddriver.google.compute.InstanceTemplates; +import com.netflix.spinnaker.clouddriver.google.config.GoogleConfigurationProperties; +import com.netflix.spinnaker.clouddriver.google.deploy.GCEUtil; +import com.netflix.spinnaker.clouddriver.google.deploy.description.StatefullyUpdateBootImageDescription; +import com.netflix.spinnaker.clouddriver.google.deploy.exception.GoogleResourceIllegalStateException; +import com.netflix.spinnaker.clouddriver.google.model.GoogleServerGroup; +import com.netflix.spinnaker.clouddriver.google.provider.view.GoogleClusterProvider; +import com.netflix.spinnaker.clouddriver.google.security.GoogleNamedAccountCredentials; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ExecutionException; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class StatefullyUpdateBootImageAtomicOperation extends GoogleAtomicOperation { + + private static final String BASE_PHASE = "STATEFULLY_UPDATE_BOOT_IMAGE"; + + private static final Random RANDOM = new Random(); + + private final GoogleClusterProvider clusterProvider; + private final GoogleComputeApiFactory computeApiFactory; + private final GoogleConfigurationProperties googleConfigurationProperties; + private final StatefullyUpdateBootImageDescription description; + + public StatefullyUpdateBootImageAtomicOperation( + GoogleClusterProvider clusterProvider, + GoogleComputeApiFactory computeApiFactory, + GoogleConfigurationProperties googleConfigurationProperties, + StatefullyUpdateBootImageDescription description) { + this.clusterProvider = clusterProvider; + this.computeApiFactory = computeApiFactory; + this.googleConfigurationProperties = googleConfigurationProperties; + this.description = description; + } + + /* + curl -X POST -H "Content-Type: application/json" -d ' + [ { "restartWithNewBootImage": { + "serverGroupName": "myapp-dev-v000", + "region": "us-east1", + "bootImage": "centos-7-v20190423", + "credentials": "my-account-name" + } } ]' localhost:7002/gce/ops + */ + @Override + public Void operate(List priorOutputs) { + + Task task = TaskRepository.threadLocalTask.get(); + + GoogleNamedAccountCredentials credentials = description.getCredentials(); + + GoogleServerGroup.View serverGroup = + GCEUtil.queryServerGroup( + clusterProvider, + description.getAccount(), + description.getRegion(), + description.getServerGroupName()); + + try { + + Image image = getImage(task, credentials); + + GoogleServerGroupManagers managers = + computeApiFactory.createServerGroupManagers(credentials, serverGroup); + + task.updateStatus( + BASE_PHASE, String.format("Retrieving server group %s.", serverGroup.getName())); + InstanceGroupManager instanceGroupManager = managers.get().execute(); + checkResourceState( + instanceGroupManager.getVersions().size() == 1, + "Found more than one instance template for the server group %s.", + description.getServerGroupName()); + checkResourceState( + instanceGroupManager.getStatefulPolicy() != null, + "Server group %s does not have a StatefulPolicy", + description.getServerGroupName()); + + String oldTemplateName = GCEUtil.getLocalName(instanceGroupManager.getInstanceTemplate()); + InstanceTemplates instanceTemplates = computeApiFactory.createInstanceTemplates(credentials); + + task.updateStatus( + BASE_PHASE, String.format("Retrieving instance template %s.", oldTemplateName)); + GoogleComputeRequest request = instanceTemplates.get(oldTemplateName); + InstanceTemplate template = request.execute(); + + String newTemplateName = getNewTemplateName(description.getServerGroupName()); + template.setName(newTemplateName); + List disks = + template.getProperties().getDisks().stream() + .filter(AttachedDisk::getBoot) + .collect(toList()); + checkState(disks.size() == 1, "Expected exactly one boot disk, found %s", disks.size()); + AttachedDisk bootDisk = disks.get(0); + bootDisk.getInitializeParams().setSourceImage(image.getSelfLink()); + + task.updateStatus( + BASE_PHASE, String.format("Saving new instance template %s.", newTemplateName)); + instanceTemplates.insert(template).executeAndWait(task, BASE_PHASE); + + instanceGroupManager + .setInstanceTemplate( + GCEUtil.buildInstanceTemplateUrl(credentials.getProject(), newTemplateName)) + .setVersions(ImmutableList.of()) + .setUpdatePolicy(new InstanceGroupManagerUpdatePolicy().setType("OPPORTUNISTIC")); + + task.updateStatus( + BASE_PHASE, String.format("Starting update of server group %s.", serverGroup.getName())); + managers.patch(instanceGroupManager).executeAndWait(task, BASE_PHASE); + + task.updateStatus( + BASE_PHASE, String.format("Deleting instance template %s.", oldTemplateName)); + instanceTemplates.delete(oldTemplateName).executeAndWait(task, BASE_PHASE); + + return null; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @NotNull + private Image getImage(Task task, GoogleNamedAccountCredentials credentials) throws IOException { + + task.updateStatus(BASE_PHASE, "Looking up image " + description.getBootImage()); + + Images imagesApi = computeApiFactory.createImages(credentials); + + SettableFuture foundImage = SettableFuture.create(); + String filter = "name eq " + description.getBootImage(); + + ComputeBatchRequest batchRequest = + computeApiFactory.createBatchRequest(credentials); + for (String project : getImageProjects(credentials)) { + GoogleComputeRequest request = imagesApi.list(project); + request.getRequest().setFilter(filter); + batchRequest.queue(request, new ImageListCallback(project, foundImage)); + } + batchRequest.execute("findImage"); + + // If #execute() returned and foundImage still hasn't been set, then we must not have found one. + // Set an exception to bubble up to the caller. (This does nothing if foundImage was already set + // with a result.) + foundImage.setException( + new GoogleResourceIllegalStateException( + "Couldn't find an image named " + description.getBootImage())); + + Image image; + try { + image = foundImage.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } catch (ExecutionException e) { + Throwables.propagateIfPossible(e.getCause()); + throw new RuntimeException(e.getCause()); + } + + return image; + } + + private ImmutableSet getImageProjects(GoogleNamedAccountCredentials credentials) { + return ImmutableSet.builder() + .add(credentials.getProject()) + .addAll(credentials.getImageProjects()) + .addAll(googleConfigurationProperties.getBaseImageProjects()) + .build(); + } + + private static class ImageListCallback extends JsonBatchCallback { + + final String project; + final SettableFuture foundImage; + + ImageListCallback(String project, SettableFuture foundImage) { + this.project = project; + this.foundImage = foundImage; + } + + @Override + public void onSuccess(ImageList imageList, HttpHeaders responseHeaders) { + if (imageList.getItems() != null && !imageList.getItems().isEmpty()) + foundImage.set(imageList.getItems().get(0)); + } + + @Override + public void onFailure(GoogleJsonError e, HttpHeaders responseHeaders) { + log.warn( + String.format("Error retrieving images from project %s: %s", project, e.getMessage())); + } + } + + private static String getNewTemplateName(String serverGroupName) { + return String.format("%s-%08d", serverGroupName, RANDOM.nextInt(100000000)); + } +} diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/validators/StatefullyUpdateBootImageDescriptionValidator.java b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/validators/StatefullyUpdateBootImageDescriptionValidator.java new file mode 100644 index 00000000000..741b26a24f5 --- /dev/null +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/deploy/validators/StatefullyUpdateBootImageDescriptionValidator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.deploy.validators; + +import com.netflix.spinnaker.clouddriver.deploy.DescriptionValidator; +import com.netflix.spinnaker.clouddriver.google.deploy.description.StatefullyUpdateBootImageDescription; +import java.util.List; +import org.springframework.validation.Errors; + +public class StatefullyUpdateBootImageDescriptionValidator + extends DescriptionValidator { + + @Override + public void validate( + List priorDescriptions, StatefullyUpdateBootImageDescription description, Errors errors) { + StandardGceAttributeValidator helper = + new StandardGceAttributeValidator("statefullyUpdateBootImageDescription", errors); + helper.validateRegion(description.getRegion(), description.getCredentials()); + helper.validateServerGroupName(description.getServerGroupName()); + helper.validateName(description.getBootImage(), "bootImage"); + } +} diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestTest.java b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImplTest.java similarity index 92% rename from clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestTest.java rename to clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImplTest.java index face1741f79..7ffef882357 100644 --- a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestTest.java +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/ComputeBatchRequestImplTest.java @@ -47,7 +47,7 @@ import org.junit.runners.JUnit4; @RunWith(JUnit4.class) -public class ComputeBatchRequestTest { +public class ComputeBatchRequestImplTest { private static final String USER_AGENT = "spinnaker-test"; private static final String MIME_BOUNDARY = "batch_foobarbaz"; @@ -68,7 +68,7 @@ public void exitsEarlyWithNoRequests() throws IOException { Compute compute = computeWithResponses(); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); batchRequest.execute("batchContext"); @@ -80,7 +80,7 @@ public void singleRequest() throws IOException { Compute compute = computeWithResponses(() -> successBatchResponse(1)); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); @@ -96,20 +96,20 @@ public void singleRequest() throws IOException { public void singleBatch() throws IOException { Compute compute = - computeWithResponses(() -> successBatchResponse(ComputeBatchRequest.MAX_BATCH_SIZE)); + computeWithResponses(() -> successBatchResponse(ComputeBatchRequestImpl.MAX_BATCH_SIZE)); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); - for (int i = 0; i < ComputeBatchRequest.MAX_BATCH_SIZE; ++i) { + for (int i = 0; i < ComputeBatchRequestImpl.MAX_BATCH_SIZE; ++i) { batchRequest.queue(request(compute), responses); } batchRequest.execute("batchContext"); - assertThat(responses.successes).hasValue(ComputeBatchRequest.MAX_BATCH_SIZE); + assertThat(responses.successes).hasValue(ComputeBatchRequestImpl.MAX_BATCH_SIZE); assertThat(responses.failures).hasValue(0); } @@ -118,22 +118,22 @@ public void multipleBatches() throws IOException { Compute compute = computeWithResponses( - () -> successBatchResponse(ComputeBatchRequest.MAX_BATCH_SIZE), - () -> successBatchResponse(ComputeBatchRequest.MAX_BATCH_SIZE), + () -> successBatchResponse(ComputeBatchRequestImpl.MAX_BATCH_SIZE), + () -> successBatchResponse(ComputeBatchRequestImpl.MAX_BATCH_SIZE), () -> successBatchResponse(37)); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); - for (int i = 0; i < ComputeBatchRequest.MAX_BATCH_SIZE * 2 + 37; ++i) { + for (int i = 0; i < ComputeBatchRequestImpl.MAX_BATCH_SIZE * 2 + 37; ++i) { batchRequest.queue(request(compute), responses); } batchRequest.execute("batchContext"); - assertThat(responses.successes).hasValue(ComputeBatchRequest.MAX_BATCH_SIZE * 2 + 37); + assertThat(responses.successes).hasValue(ComputeBatchRequestImpl.MAX_BATCH_SIZE * 2 + 37); assertThat(responses.failures).hasValue(0); } @@ -151,7 +151,7 @@ public void handlesErrors() throws IOException { Compute compute = computeWithResponses(() -> batchResponse(responseContent.toString())); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); @@ -170,7 +170,7 @@ public void propagatesFirstException() throws IOException { Compute compute = computeWithResponses( - () -> successBatchResponse(ComputeBatchRequest.MAX_BATCH_SIZE), + () -> successBatchResponse(ComputeBatchRequestImpl.MAX_BATCH_SIZE), () -> { throw new IOException("first exception"); }, @@ -187,11 +187,11 @@ public void propagatesFirstException() throws IOException { }); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); - for (int i = 0; i < ComputeBatchRequest.MAX_BATCH_SIZE * 3; ++i) { + for (int i = 0; i < ComputeBatchRequestImpl.MAX_BATCH_SIZE * 3; ++i) { batchRequest.queue(request(compute), responses); } @@ -205,16 +205,16 @@ public void successMetrics() throws IOException { Compute compute = computeWithResponses( - () -> successBatchResponse(ComputeBatchRequest.MAX_BATCH_SIZE), - () -> successBatchResponse(ComputeBatchRequest.MAX_BATCH_SIZE), + () -> successBatchResponse(ComputeBatchRequestImpl.MAX_BATCH_SIZE), + () -> successBatchResponse(ComputeBatchRequestImpl.MAX_BATCH_SIZE), () -> successBatchResponse(37)); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); - for (int i = 0; i < ComputeBatchRequest.MAX_BATCH_SIZE * 2 + 37; ++i) { + for (int i = 0; i < ComputeBatchRequestImpl.MAX_BATCH_SIZE * 2 + 37; ++i) { batchRequest.queue(request(compute), responses); } @@ -239,7 +239,7 @@ public void successMetrics() throws IOException { tag("success", "true"), tag("status", "2xx"), tag("statusCode", "200")); - assertThat(counter.actualCount()).isEqualTo(ComputeBatchRequest.MAX_BATCH_SIZE * 2 + 37); + assertThat(counter.actualCount()).isEqualTo(ComputeBatchRequestImpl.MAX_BATCH_SIZE * 2 + 37); } @Test @@ -252,7 +252,7 @@ public void errorMetrics() throws IOException { }); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); @@ -294,7 +294,7 @@ public void httpErrorMetrics() throws IOException { }); ComputeBatchRequest batchRequest = - new ComputeBatchRequest<>( + new ComputeBatchRequestImpl<>( compute, registry, USER_AGENT, MoreExecutors.newDirectExecutorService()); CountResponses responses = new CountResponses(); diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeComputeBatchRequest.java b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeComputeBatchRequest.java new file mode 100644 index 00000000000..7a3d0b4c3a3 --- /dev/null +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeComputeBatchRequest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.compute; + +import com.google.api.client.googleapis.batch.json.JsonBatchCallback; +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.http.HttpHeaders; +import com.google.api.services.compute.ComputeRequest; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public final class FakeComputeBatchRequest, ResponseT> + implements ComputeBatchRequest { + + private List requests = new ArrayList<>(); + + @Override + public void queue( + GoogleComputeRequest request, JsonBatchCallback callback) { + requests.add(new QueuedRequest(request, callback)); + } + + @Override + public void execute(String batchContext) throws IOException { + for (QueuedRequest request : requests) { + try { + request.callback.onSuccess(request.request.execute(), new HttpHeaders()); + } catch (IOException | RuntimeException e) { + request.callback.onFailure(new GoogleJsonError(), new HttpHeaders()); + } + } + } + + private final class QueuedRequest { + GoogleComputeRequest request; + JsonBatchCallback callback; + + QueuedRequest( + GoogleComputeRequest request, JsonBatchCallback callback) { + this.request = request; + this.callback = callback; + } + } +} diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeGoogleComputeOperationRequest.java b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeGoogleComputeOperationRequest.java index 5fd3588e9c8..514b2a0ab97 100644 --- a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeGoogleComputeOperationRequest.java +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/compute/FakeGoogleComputeOperationRequest.java @@ -27,6 +27,10 @@ public class FakeGoogleComputeOperationRequest, ResponseT> implements GoogleComputeRequest { + private final RequestT request; private final ResponseT response; private boolean executed = false; public FakeGoogleComputeRequest(ResponseT response) { + this(null, response); + } + + public FakeGoogleComputeRequest(RequestT request, ResponseT response) { + this.request = request; this.response = response; } @Override - public ResponseT execute() throws IOException { + public ResponseT execute() { executed = true; return response; } @Override public RequestT getRequest() { - throw new UnsupportedOperationException("FakeGoogleComputeRequest#getRequest()"); + if (request == null) { + throw new UnsupportedOperationException("FakeGoogleComputeRequest#getRequest()"); + } + return request; } public boolean executed() { diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/SetStatefulDiskAtomicOperationUnitSpec.groovy b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/SetStatefulDiskAtomicOperationUnitSpec.groovy index e70cb71b637..c418cfedc83 100644 --- a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/SetStatefulDiskAtomicOperationUnitSpec.groovy +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/SetStatefulDiskAtomicOperationUnitSpec.groovy @@ -72,7 +72,7 @@ class SetStatefulDiskAtomicOperationUnitSpec extends Specification { deviceName: DEVICE_NAME, credentials: CREDENTIALS) def operation = new SetStatefulDiskAtomicOperation(clusterProvider, computeApiFactory, description) - def updateOp = new FakeGoogleComputeOperationRequest<>(new Operation()) + def updateOp = new FakeGoogleComputeOperationRequest<>() def getManagerRequest = new FakeGoogleComputeRequest<>(new InstanceGroupManager()) _ * serverGroupManagers.get() >> getManagerRequest diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperationTest.java b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperationTest.java new file mode 100644 index 00000000000..a33054c34eb --- /dev/null +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/deploy/ops/StatefullyUpdateBootImageAtomicOperationTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2019 Google, 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.clouddriver.google.deploy.ops; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.services.compute.Compute; +import com.google.api.services.compute.Compute.InstanceTemplates.Delete; +import com.google.api.services.compute.Compute.InstanceTemplates.Insert; +import com.google.api.services.compute.model.AttachedDisk; +import com.google.api.services.compute.model.AttachedDiskInitializeParams; +import com.google.api.services.compute.model.Image; +import com.google.api.services.compute.model.ImageList; +import com.google.api.services.compute.model.InstanceGroupManager; +import com.google.api.services.compute.model.InstanceGroupManagerVersion; +import com.google.api.services.compute.model.InstanceProperties; +import com.google.api.services.compute.model.InstanceTemplate; +import com.google.api.services.compute.model.StatefulPolicy; +import com.google.common.collect.ImmutableList; +import com.netflix.spinnaker.clouddriver.data.task.DefaultTask; +import com.netflix.spinnaker.clouddriver.data.task.TaskRepository; +import com.netflix.spinnaker.clouddriver.google.compute.FakeComputeBatchRequest; +import com.netflix.spinnaker.clouddriver.google.compute.FakeGoogleComputeOperationRequest; +import com.netflix.spinnaker.clouddriver.google.compute.FakeGoogleComputeRequest; +import com.netflix.spinnaker.clouddriver.google.compute.GoogleComputeApiFactory; +import com.netflix.spinnaker.clouddriver.google.compute.GoogleServerGroupManagers; +import com.netflix.spinnaker.clouddriver.google.compute.Images; +import com.netflix.spinnaker.clouddriver.google.compute.InstanceTemplates; +import com.netflix.spinnaker.clouddriver.google.config.GoogleConfigurationProperties; +import com.netflix.spinnaker.clouddriver.google.deploy.description.StatefullyUpdateBootImageDescription; +import com.netflix.spinnaker.clouddriver.google.deploy.exception.GoogleResourceIllegalStateException; +import com.netflix.spinnaker.clouddriver.google.model.GoogleServerGroup; +import com.netflix.spinnaker.clouddriver.google.provider.view.GoogleClusterProvider; +import com.netflix.spinnaker.clouddriver.google.security.FakeGoogleCredentials; +import com.netflix.spinnaker.clouddriver.google.security.GoogleNamedAccountCredentials; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.runner.JUnitPlatform; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@RunWith(JUnitPlatform.class) +@ExtendWith(MockitoExtension.class) +final class StatefullyUpdateBootImageAtomicOperationTest { + + private static final String SERVER_GROUP = "testapp-v000"; + private static final String REGION = "us-central1"; + private static final String IMAGE_NAME = "kool-new-os"; + private static final String IMAGE_URL = "http://cloud.google.com/images/my-project/" + IMAGE_NAME; + private static final String INSTANCE_TEMPLATE_NAME = SERVER_GROUP + "-1234567890"; + private static final String INSTANCE_TEMPLATE_URL = + "http://cloud.google.com/instance-templates/my-project/" + INSTANCE_TEMPLATE_NAME; + + @Mock private GoogleServerGroupManagers mockServerGroupManagers; + @Mock private Images mockImages; + @Mock private InstanceTemplates mockInstanceTemplates; + + private StatefullyUpdateBootImageAtomicOperation operation; + + @BeforeEach + void setUp() { + TaskRepository.threadLocalTask.set(new DefaultTask("taskId")); + + GoogleNamedAccountCredentials credentials = + new GoogleNamedAccountCredentials.Builder() + .name("spinnaker-account") + .credentials(new FakeGoogleCredentials()) + .project("foo") + .build(); + + GoogleConfigurationProperties config = new GoogleConfigurationProperties(); + config.setBaseImageProjects(ImmutableList.of("projectOne", "projectTwo")); + + GoogleClusterProvider mockClusterProvider = mock(GoogleClusterProvider.class); + when(mockClusterProvider.getServerGroup(any(), any(), any())) + .thenReturn(new GoogleServerGroup(SERVER_GROUP).getView()); + + StatefullyUpdateBootImageDescription description = + new StatefullyUpdateBootImageDescription() + .setServerGroupName(SERVER_GROUP) + .setRegion(REGION) + .setBootImage(IMAGE_NAME) + .setCredentials(credentials); + + GoogleComputeApiFactory mockComputeApiFactory = mock(GoogleComputeApiFactory.class); + lenient() + .when(mockComputeApiFactory.createServerGroupManagers(any(), any())) + .thenReturn(mockServerGroupManagers); + lenient().when(mockComputeApiFactory.createImages(any())).thenReturn(mockImages); + lenient() + .when(mockComputeApiFactory.createInstanceTemplates(any())) + .thenReturn(mockInstanceTemplates); + lenient() + .when(mockComputeApiFactory.createBatchRequest(any())) + .thenReturn(new FakeComputeBatchRequest<>()); + + operation = + new StatefullyUpdateBootImageAtomicOperation( + mockClusterProvider, mockComputeApiFactory, config, description); + } + + @Test + void couldNotFindImage() throws IOException { + when(mockImages.list(any())).thenReturn(images()); + + Exception e = + assertThrows( + GoogleResourceIllegalStateException.class, () -> operation.operate(ImmutableList.of())); + assertThat(e).hasMessageContaining(IMAGE_NAME); + } + + @Test + void exceptionFindingImage() throws IOException { + when(mockImages.list(any())).thenThrow(new IOException("uh oh")); + + Exception e = assertThrows(Exception.class, () -> operation.operate(ImmutableList.of())); + assertThat(e).hasMessageContaining("uh oh"); + } + + @Test + void multipleInstanceGroupTemplates() throws IOException { + when(mockImages.list(any())).thenReturn(images(baseImage())); + when(mockServerGroupManagers.get()) + .thenReturn( + new FakeGoogleComputeRequest<>( + baseInstanceGroupManager() + .setVersions( + ImmutableList.of( + new InstanceGroupManagerVersion(), + new InstanceGroupManagerVersion())))); + + Exception e = assertThrows(Exception.class, () -> operation.operate(ImmutableList.of())); + assertThat(e).hasMessageContaining("more than one instance template"); + } + + @Test + void noStatefulPolicy() throws IOException { + when(mockImages.list(any())).thenReturn(images(baseImage())); + when(mockServerGroupManagers.get()) + .thenReturn( + new FakeGoogleComputeRequest<>(baseInstanceGroupManager().setStatefulPolicy(null))); + + Exception e = assertThrows(Exception.class, () -> operation.operate(ImmutableList.of())); + assertThat(e).hasMessageContaining("StatefulPolicy"); + } + + @Test + void multipleBootDisks() throws IOException { + when(mockImages.list(any())).thenReturn(images(baseImage())); + when(mockServerGroupManagers.get()) + .thenReturn(new FakeGoogleComputeRequest<>(baseInstanceGroupManager())); + InstanceTemplate instanceTemplate = baseInstanceTemplate(); + instanceTemplate + .getProperties() + .setDisks( + ImmutableList.of(new AttachedDisk().setBoot(true), new AttachedDisk().setBoot(true))); + when(mockInstanceTemplates.get(any())) + .thenReturn(new FakeGoogleComputeRequest<>(instanceTemplate)); + + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> operation.operate(ImmutableList.of())); + + assertThat(e).hasMessageContaining("one boot disk"); + } + + @Test + void success() throws IOException { + when(mockImages.list(any())).thenReturn(images(new Image().setSelfLink(IMAGE_URL))); + when(mockServerGroupManagers.get()) + .thenReturn(new FakeGoogleComputeRequest<>(baseInstanceGroupManager())); + when(mockInstanceTemplates.get(any())) + .thenReturn(new FakeGoogleComputeRequest<>(baseInstanceTemplate())); + FakeGoogleComputeOperationRequest insertOp = new FakeGoogleComputeOperationRequest<>(); + when(mockInstanceTemplates.insert(any())).thenReturn(insertOp); + FakeGoogleComputeOperationRequest deleteOp = new FakeGoogleComputeOperationRequest<>(); + when(mockInstanceTemplates.delete(any())).thenReturn(deleteOp); + FakeGoogleComputeOperationRequest patchOp = new FakeGoogleComputeOperationRequest(); + when(mockServerGroupManagers.patch(any())).thenReturn(patchOp); + + operation.operate(ImmutableList.of()); + + ArgumentCaptor newTemplateCaptor = + ArgumentCaptor.forClass(InstanceTemplate.class); + verify(mockInstanceTemplates).insert(newTemplateCaptor.capture()); + InstanceTemplate newTemplate = newTemplateCaptor.getValue(); + + assertThat(newTemplate.getName()).matches(SERVER_GROUP + "-\\d{8}"); + AttachedDisk bootDisk = newTemplate.getProperties().getDisks().get(0); + assertThat(bootDisk.getInitializeParams().getSourceImage()).isEqualTo(IMAGE_URL); + assertThat(insertOp.waitedForCompletion()).isTrue(); + + ArgumentCaptor patchedManagerCaptor = + ArgumentCaptor.forClass(InstanceGroupManager.class); + verify(mockServerGroupManagers).patch(patchedManagerCaptor.capture()); + InstanceGroupManager patchedManager = patchedManagerCaptor.getValue(); + + assertThat(patchedManager.getInstanceTemplate()).endsWith("/" + newTemplate.getName()); + assertThat(patchedManager.getVersions()).isEmpty(); + assertThat(patchedManager.getUpdatePolicy().getType()).isEqualTo("OPPORTUNISTIC"); + assertThat(patchOp.waitedForCompletion()).isTrue(); + + verify(mockInstanceTemplates).delete(INSTANCE_TEMPLATE_NAME); + assertThat(deleteOp.waitedForCompletion()).isTrue(); + } + + private static Image baseImage() { + return new Image().setName(IMAGE_NAME).setSelfLink(IMAGE_URL); + } + + private static FakeGoogleComputeRequest images(Image... images) { + return new FakeGoogleComputeRequest<>( + mock(Compute.Images.List.class), new ImageList().setItems(ImmutableList.copyOf(images))); + } + + private static InstanceTemplate baseInstanceTemplate() { + return new InstanceTemplate() + .setName(INSTANCE_TEMPLATE_NAME) + .setSelfLink(INSTANCE_TEMPLATE_URL) + .setProperties( + new InstanceProperties() + .setDisks( + ImmutableList.of( + new AttachedDisk() + .setBoot(true) + .setInitializeParams( + new AttachedDiskInitializeParams().setSourceImage("centos")), + new AttachedDisk().setBoot(false)))); + } + + private static InstanceGroupManager baseInstanceGroupManager() { + return new InstanceGroupManager() + .setInstanceTemplate(INSTANCE_TEMPLATE_URL) + .setVersions( + ImmutableList.of( + new InstanceGroupManagerVersion().setInstanceTemplate(INSTANCE_TEMPLATE_URL))) + .setStatefulPolicy(new StatefulPolicy()); + } +}