diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/config/GoogleConfigurationProperties.groovy b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/config/GoogleConfigurationProperties.groovy index 700a78a4e41..587811aafbe 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/config/GoogleConfigurationProperties.groovy +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/clouddriver/google/config/GoogleConfigurationProperties.groovy @@ -18,12 +18,25 @@ package com.netflix.spinnaker.clouddriver.google.config import com.netflix.spinnaker.clouddriver.consul.config.ConsulConfig import com.netflix.spinnaker.clouddriver.googlecommon.config.GoogleCommonManagedAccount +import groovy.transform.Canonical import groovy.transform.ToString +import org.springframework.boot.context.properties.NestedConfigurationProperty class GoogleConfigurationProperties { public static final int ASYNC_OPERATION_TIMEOUT_SECONDS_DEFAULT = 300 public static final int ASYNC_OPERATION_MAX_POLLING_INTERVAL_SECONDS = 8 + /** + * health check related config settings + */ + @Canonical + static class HealthConfig { + /** + * flag to toggle verifying account health check. by default, account health check is enabled. + */ + boolean verifyAccountHealth = true + } + @ToString(includeNames = true) static class ManagedAccount extends GoogleCommonManagedAccount { boolean alphaListed @@ -45,4 +58,7 @@ class GoogleConfigurationProperties { // Takes a list of regions you want indexed. Will default to indexing all regions if left // unspecified. An empty list will index no regions. List defaultRegions + + @NestedConfigurationProperty + final HealthConfig health = new HealthConfig() } diff --git a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/config/GoogleConfiguration.groovy b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/config/GoogleConfiguration.groovy index 6d9a505a11e..c1d85b5155f 100644 --- a/clouddriver-google/src/main/groovy/com/netflix/spinnaker/config/GoogleConfiguration.groovy +++ b/clouddriver-google/src/main/groovy/com/netflix/spinnaker/config/GoogleConfiguration.groovy @@ -16,7 +16,6 @@ package com.netflix.spinnaker.config - import com.netflix.spinnaker.clouddriver.google.config.GoogleConfigurationProperties import com.netflix.spinnaker.clouddriver.google.config.GoogleCredentialsConfiguration @@ -49,6 +48,7 @@ class GoogleConfiguration { } @Bean + @ConditionalOnProperty("google.health.verifyAccountHealth") GoogleHealthIndicator googleHealthIndicator() { new GoogleHealthIndicator() } diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/health/GoogleHealthIndicatorSpec.groovy b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/health/GoogleHealthIndicatorSpec.groovy new file mode 100644 index 00000000000..0855a3ae71b --- /dev/null +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/health/GoogleHealthIndicatorSpec.groovy @@ -0,0 +1,123 @@ +/* + * Copyright 2023 Armory, 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.health + + +import com.google.api.services.compute.model.Project +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableMap +import com.netflix.spectator.api.NoopRegistry +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.clouddriver.google.provider.agent.StubComputeFactory +import com.netflix.spinnaker.clouddriver.google.security.GoogleNamedAccountCredentials +import com.netflix.spinnaker.credentials.CredentialsRepository +import com.netflix.spinnaker.credentials.CredentialsTypeBaseConfiguration +import org.springframework.boot.actuate.health.Status +import org.springframework.context.ApplicationContext +import spock.lang.Specification +import spock.lang.Unroll + +class GoogleHealthIndicatorSpec extends Specification { + + private static final String ACCOUNT_NAME = "partypups" + private static final String PROJECT = "myproject" + private static final String REGION = "myregion" + private static final String ZONE = REGION + "-myzone" + private static final Registry REGISTRY = new NoopRegistry() + + @Unroll + def "health succeeds when google is reachable"() { + setup: + def applicationContext = Mock(ApplicationContext) + def project = new Project() + project.setName(PROJECT) + + def compute = new StubComputeFactory() + .setProjects(project) + .create() + + def googleNamedAccountCredentials = + new GoogleNamedAccountCredentials.Builder() + .project(PROJECT) + .name(ACCOUNT_NAME) + .compute(compute) + .regionToZonesMap(ImmutableMap.of(REGION, ImmutableList.of(ZONE))) + .build() + + def credentials = [googleNamedAccountCredentials] + def credentialsRepository = Stub(CredentialsRepository) { + getAll() >> credentials + } + + def credentialsTypeBaseConfiguration = new CredentialsTypeBaseConfiguration(applicationContext, null) + credentialsTypeBaseConfiguration.credentialsRepository = credentialsRepository + + def indicator = new GoogleHealthIndicator() + indicator.registry = REGISTRY + indicator.credentialsTypeBaseConfiguration = credentialsTypeBaseConfiguration + + when: + indicator.checkHealth() + def health = indicator.health() + + then: + health.status == Status.UP + health.details.isEmpty() + } + + @Unroll + def "health throws exception when google appears unreachable"() { + setup: + def applicationContext = Mock(ApplicationContext) + def project = new Project() + project.setName(PROJECT) + + def compute = new StubComputeFactory() + .setProjects(project) + .setProjectException(new IOException("Read timed out")) + .create() + + def googleNamedAccountCredentials = + new GoogleNamedAccountCredentials.Builder() + .project(PROJECT) + .name(ACCOUNT_NAME) + .compute(compute) + .regionToZonesMap(ImmutableMap.of(REGION, ImmutableList.of(ZONE))) + .build() + + def credentials = [googleNamedAccountCredentials] + def credentialsRepository = Stub(CredentialsRepository) { + getAll() >> credentials + } + + def credentialsTypeBaseConfiguration = new CredentialsTypeBaseConfiguration(applicationContext, null) + credentialsTypeBaseConfiguration.credentialsRepository = credentialsRepository + + def indicator = new GoogleHealthIndicator() + indicator.registry = REGISTRY + indicator.credentialsTypeBaseConfiguration = credentialsTypeBaseConfiguration + + when: + indicator.checkHealth() + def health = indicator.health() + + then: + thrown(GoogleHealthIndicator.GoogleIOException) + + health == null + } +} diff --git a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/provider/agent/StubComputeFactory.java b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/provider/agent/StubComputeFactory.java index daa7e1326cb..e61166f14bc 100644 --- a/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/provider/agent/StubComputeFactory.java +++ b/clouddriver-google/src/test/groovy/com/netflix/spinnaker/clouddriver/google/provider/agent/StubComputeFactory.java @@ -32,19 +32,7 @@ import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.api.services.compute.Compute; -import com.google.api.services.compute.model.Autoscaler; -import com.google.api.services.compute.model.AutoscalerAggregatedList; -import com.google.api.services.compute.model.AutoscalerList; -import com.google.api.services.compute.model.AutoscalersScopedList; -import com.google.api.services.compute.model.Instance; -import com.google.api.services.compute.model.InstanceAggregatedList; -import com.google.api.services.compute.model.InstanceGroupManager; -import com.google.api.services.compute.model.InstanceGroupManagerList; -import com.google.api.services.compute.model.InstanceList; -import com.google.api.services.compute.model.InstanceTemplate; -import com.google.api.services.compute.model.InstanceTemplateList; -import com.google.api.services.compute.model.InstancesScopedList; -import com.google.api.services.compute.model.RegionInstanceGroupManagerList; +import com.google.api.services.compute.model.*; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; @@ -72,8 +60,10 @@ final class StubComputeFactory { private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + private static final String COMPUTE_PATH_PREFIX = "/compute/[-.a-zA-Z0-9]+"; + private static final String COMPUTE_PROJECT_PATH_PREFIX = - "/compute/[-.a-zA-Z0-9]+/projects/[-.a-zA-Z0-9]+"; + COMPUTE_PATH_PREFIX + "/projects/[-.a-zA-Z0-9]+"; private static final Pattern BATCH_COMPUTE_PATTERN = Pattern.compile("/batch/compute/[-.a-zA-Z0-9]+"); @@ -114,11 +104,15 @@ final class StubComputeFactory { Pattern.compile(COMPUTE_PROJECT_PATH_PREFIX + "/regions/([-a-z0-9]+)/autoscalers"); private static final Pattern AGGREGATED_AUTOSCALERS_PATTERN = Pattern.compile(COMPUTE_PROJECT_PATH_PREFIX + "/aggregated/autoscalers"); + private static final Pattern GET_PROJECT_PATTERN = + Pattern.compile(COMPUTE_PATH_PREFIX + "/projects/([-.a-zA-Z0-9]+)"); private List instanceGroupManagers = new ArrayList<>(); private List instanceTemplates = new ArrayList<>(); private List instances = new ArrayList<>(); private List autoscalers = new ArrayList<>(); + private List projects = new ArrayList<>(); + private Exception projectException; StubComputeFactory setInstanceGroupManagers(InstanceGroupManager... instanceGroupManagers) { this.instanceGroupManagers = ImmutableList.copyOf(instanceGroupManagers); @@ -140,6 +134,16 @@ StubComputeFactory setAutoscalers(Autoscaler... autoscalers) { return this; } + StubComputeFactory setProjects(Project... projects) { + this.projects = ImmutableList.copyOf(projects); + return this; + } + + StubComputeFactory setProjectException(Exception projectException) { + this.projectException = projectException; + return this; + } + Compute create() { HttpTransport httpTransport = new StubHttpTransport() @@ -166,7 +170,8 @@ Compute create() { .addGetResponse( LIST_REGIONAL_AUTOSCALERS_PATTERN, new PathBasedJsonResponseGenerator(this::regionalAutoscalerList)) - .addGetResponse(AGGREGATED_AUTOSCALERS_PATTERN, this::autoscalerAggregatedList); + .addGetResponse(AGGREGATED_AUTOSCALERS_PATTERN, this::autoscalerAggregatedList) + .addGetResponse(GET_PROJECT_PATTERN, this::project); return new Compute( httpTransport, JacksonFactory.getDefaultInstance(), /* httpRequestInitializer= */ null); } @@ -322,6 +327,21 @@ private MockLowLevelHttpResponse autoscalerAggregatedList(LowLevelHttpRequest re return jsonResponse(new AutoscalerAggregatedList().setItems(autoscalers)); } + private MockLowLevelHttpResponse project(MockLowLevelHttpRequest request) { + if (projectException != null) { + return errorResponse(500, projectException); + } + + Matcher matcher = GET_PROJECT_PATTERN.matcher(getPath(request)); + checkState(matcher.matches()); + String name = matcher.group(1); + return projects.stream() + .filter(project -> name.equals(project.getName())) + .findFirst() + .map(StubComputeFactory::jsonResponse) + .orElse(errorResponse(404)); + } + private static ImmutableListMultimap aggregate( Collection items, Function zoneFunction, Function regionFunction) { return items.stream() @@ -345,9 +365,18 @@ private static String getAggregateKey( } private static MockLowLevelHttpResponse errorResponse(int statusCode) { + return errorResponse(statusCode, null); + } + + private static MockLowLevelHttpResponse errorResponse(int statusCode, Exception exception) { GoogleJsonErrorContainer errorContainer = new GoogleJsonErrorContainer(); GoogleJsonError error = new GoogleJsonError(); error.setCode(statusCode); + + if (exception != null) { + error.setMessage(exception.getMessage()); + } + errorContainer.setError(error); return jsonResponse(statusCode, errorContainer); }