diff --git a/clouddriver-aws/clouddriver-aws.gradle b/clouddriver-aws/clouddriver-aws.gradle index 49298a30815..9c738db0878 100644 --- a/clouddriver-aws/clouddriver-aws.gradle +++ b/clouddriver-aws/clouddriver-aws.gradle @@ -50,6 +50,7 @@ dependencies { implementation "com.squareup.retrofit:converter-jackson" implementation "com.squareup.retrofit:retrofit" implementation "io.reactivex:rxjava" + implementation "org.apache.commons:commons-lang3" implementation "org.apache.httpcomponents:httpclient" implementation "org.apache.httpcomponents:httpcore" implementation "org.codehaus.groovy:groovy-all" diff --git a/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/model/AmazonServerCertificate.groovy b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/model/AmazonServerCertificate.groovy index 65e8cd96525..d57f77da928 100644 --- a/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/model/AmazonServerCertificate.groovy +++ b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/model/AmazonServerCertificate.groovy @@ -19,7 +19,7 @@ package com.netflix.spinnaker.clouddriver.aws.model import com.netflix.spinnaker.clouddriver.model.Certificate import groovy.transform.Canonical -@Canonical +@Canonical(includeSuperProperties=true, includeSuperFields=true) class AmazonCertificate extends Certificate { String arn Date uploadDate diff --git a/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/provider/agent/AwsCertificateManagerCachingAgent.groovy b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/provider/agent/AwsCertificateManagerCachingAgent.groovy new file mode 100644 index 00000000000..37eac93fc18 --- /dev/null +++ b/clouddriver-aws/src/main/groovy/com/netflix/spinnaker/clouddriver/aws/provider/agent/AwsCertificateManagerCachingAgent.groovy @@ -0,0 +1,184 @@ +/* + * Copyright 2021 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * This file 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.aws.provider.agent + +import com.amazonaws.services.certificatemanager.AWSCertificateManager +import com.amazonaws.services.certificatemanager.model.AWSCertificateManagerException +import com.amazonaws.services.certificatemanager.model.CertificateDetail +import com.amazonaws.services.certificatemanager.model.CertificateSummary +import com.amazonaws.services.certificatemanager.model.DescribeCertificateRequest +import com.amazonaws.services.certificatemanager.model.DescribeCertificateResult +import com.amazonaws.services.certificatemanager.model.ListCertificatesRequest +import com.amazonaws.services.certificatemanager.model.ListCertificatesResult +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.spectator.api.Id +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.cats.agent.AccountAware +import com.netflix.spinnaker.cats.agent.AgentDataType +import com.netflix.spinnaker.cats.agent.CacheResult +import com.netflix.spinnaker.cats.agent.CachingAgent +import com.netflix.spinnaker.cats.agent.DefaultCacheResult +import com.netflix.spinnaker.cats.cache.CacheData +import com.netflix.spinnaker.cats.cache.DefaultCacheData +import com.netflix.spinnaker.cats.provider.ProviderCache +import com.netflix.spinnaker.clouddriver.aws.cache.Keys +import com.netflix.spinnaker.clouddriver.aws.model.AmazonCertificate +import com.netflix.spinnaker.clouddriver.aws.provider.AwsInfrastructureProvider +import com.netflix.spinnaker.clouddriver.aws.security.AmazonClientProvider +import com.netflix.spinnaker.clouddriver.aws.security.NetflixAmazonCredentials +import groovy.util.logging.Slf4j +import org.apache.commons.lang3.StringUtils + +import java.time.Duration +import java.time.Instant + +import static com.netflix.spinnaker.cats.agent.AgentDataType.Authority.AUTHORITATIVE +import static com.netflix.spinnaker.clouddriver.aws.cache.Keys.Namespace.CERTIFICATES + +@Slf4j +class AwsCertificateManagerCachingAgent implements CachingAgent, AccountAware { + final AmazonClientProvider amazonClientProvider + final NetflixAmazonCredentials account + final String region + final ObjectMapper objectMapper + final Registry registry + final Id securityTokenExceptionGauge + + protected Instant lastFailure + + static final Set types = Collections.unmodifiableSet([ + AUTHORITATIVE.forType(CERTIFICATES.ns) + ] as Set) + + protected static final Duration RETRY_DELAY = Duration.ofMinutes(10) + + AwsCertificateManagerCachingAgent(AmazonClientProvider amazonClientProvider, + NetflixAmazonCredentials account, + String region, + ObjectMapper objectMapper, + Registry registry) { + this.amazonClientProvider = amazonClientProvider + this.account = account + this.region = region + this.objectMapper = objectMapper + this.registry = registry + this.securityTokenExceptionGauge = registry.createId("aws.certificateCache.errors", + "account", account.name, + "account_id", account.accountId, + "region", region) + } + + @Override + String getAccountName() { + account.name + } + + @Override + String getAgentType() { + "${account.name}/${region}/${AwsCertificateManagerCachingAgent.simpleName}" + } + + @Override + String getProviderName() { + AwsInfrastructureProvider.PROVIDER_NAME + } + + @Override + Collection getProvidedDataTypes() { + types + } + + @Override + CacheResult loadData(ProviderCache providerCache) { + if (!lastFailure || lastFailure.isBefore(Instant.now() - RETRY_DELAY)) { + log.info("Describing items in ${agentType}") + AWSCertificateManager certificateManager = amazonClientProvider.getAwsCertificateManager(account, region) + + List certificateSummaries = listAllCertificates(certificateManager) + + List data = certificateSummaries.findResults { + buildCacheData(certificateManager, it) + } + + log.info("Caching ${data.size()} items in ${agentType}") + return new DefaultCacheResult([(CERTIFICATES.ns): data]) + } + new DefaultCacheResult([:]) + } + + private List listAllCertificates(AWSCertificateManager certificateManager) { + List certificateSummaries = [] + ListCertificatesRequest listCertificatesRequest = new ListCertificatesRequest() + + while (true) { + try { + ListCertificatesResult result = certificateManager.listCertificates(listCertificatesRequest) + registry.gauge(securityTokenExceptionGauge.withTag("operation", "ListCertificates")).set(0) + certificateSummaries.addAll(result.certificateSummaryList) + if (result.nextToken) { + listCertificatesRequest.withNextToken(result.nextToken) + } else { + break + } + } catch (AWSCertificateManagerException exception) { + lastFailure = Instant.now() + log.warn("An error occurred while querying AWS Certificate Manager certificates in account ${account.name} " + + "(${account.accountId}) in region ${region}. Will not retry for the next ${RETRY_DELAY.toMinutes()} " + + "minutes. Details: \n${exception.message}") + registry.gauge(securityTokenExceptionGauge.withTag("operation", "ListCertificates")).set(1) + break + } + } + certificateSummaries + } + + private DefaultCacheData buildCacheData( + AWSCertificateManager certificateManager, CertificateSummary certificateSummary) { + DescribeCertificateRequest request = new DescribeCertificateRequest() + .withCertificateArn(certificateSummary.certificateArn) + try { + DescribeCertificateResult result = certificateManager.describeCertificate(request) + registry.gauge(securityTokenExceptionGauge.withTag("operation", "DescribeCertificate")).set(0) + CertificateDetail acmCertificate = result.certificate + AmazonCertificate amazonCertificate = translateCertificate(acmCertificate) + + Map attributes = objectMapper.convertValue(amazonCertificate, + AwsInfrastructureProvider.ATTRIBUTES) + + return new DefaultCacheData( + Keys.getCertificateKey(amazonCertificate.serverCertificateId, region, account.name, "acm"), + attributes, + [:]) + } catch (AWSCertificateManagerException exception) { + lastFailure = Instant.now() + log.warn("An error occurred while describing AWS Certificate Manager certificate " + + "${certificateSummary.certificateArn} in account ${account.name} (${account.accountId}) in region ${region}. " + + "Will not retry for the next ${RETRY_DELAY.toMinutes()} minutes. Details: \n${exception.message}") + registry.gauge(securityTokenExceptionGauge.withTag("operation", "DescribeCertificate")).set(1) + return null + } + } + + private static AmazonCertificate translateCertificate(CertificateDetail certificate) { + new AmazonCertificate( + expiration: certificate.notAfter, + path: "", + serverCertificateId: StringUtils.substringAfter(certificate.certificateArn, ":certificate/"), + serverCertificateName: certificate.domainName, + arn: certificate.certificateArn, + uploadDate: certificate.createdAt) + } +} diff --git a/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/provider/config/ProviderHelpers.java b/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/provider/config/ProviderHelpers.java index 99b43dc0334..f40e034f1c4 100644 --- a/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/provider/config/ProviderHelpers.java +++ b/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/provider/config/ProviderHelpers.java @@ -189,6 +189,9 @@ public static BuildResult buildAwsProviderAgents( newlyAddedAgents.add( new AmazonCertificateCachingAgent( amazonClientProvider, credentials, region.getName(), objectMapper, registry)); + newlyAddedAgents.add( + new AwsCertificateManagerCachingAgent( + amazonClientProvider, credentials, region.getName(), objectMapper, registry)); if (dynamicConfigService.isEnabled("aws.features.cloud-formation", false)) { newlyAddedAgents.add( diff --git a/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/security/AmazonClientProvider.java b/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/security/AmazonClientProvider.java index fa17b883acd..873c5246a95 100644 --- a/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/security/AmazonClientProvider.java +++ b/clouddriver-aws/src/main/java/com/netflix/spinnaker/clouddriver/aws/security/AmazonClientProvider.java @@ -24,6 +24,8 @@ import com.amazonaws.services.applicationautoscaling.AWSApplicationAutoScalingClientBuilder; import com.amazonaws.services.autoscaling.AmazonAutoScaling; import com.amazonaws.services.autoscaling.AmazonAutoScalingClientBuilder; +import com.amazonaws.services.certificatemanager.AWSCertificateManager; +import com.amazonaws.services.certificatemanager.AWSCertificateManagerClientBuilder; import com.amazonaws.services.cloudformation.AmazonCloudFormation; import com.amazonaws.services.cloudformation.AmazonCloudFormationClientBuilder; import com.amazonaws.services.cloudwatch.AmazonCloudWatch; @@ -648,4 +650,19 @@ public AWSSupport getAmazonSupport(NetflixAmazonCredentials amazonCredentials, S return proxyHandlerBuilder.getProxyHandler( AWSSupport.class, AWSSupportClientBuilder.class, amazonCredentials, region, true); } + + public AWSCertificateManager getAwsCertificateManager( + NetflixAmazonCredentials amazonCredentials, String region) { + return getAwsCertificateManager(amazonCredentials, region, false); + } + + public AWSCertificateManager getAwsCertificateManager( + NetflixAmazonCredentials amazonCredentials, String region, boolean skipEdda) { + return proxyHandlerBuilder.getProxyHandler( + AWSCertificateManager.class, + AWSCertificateManagerClientBuilder.class, + amazonCredentials, + region, + skipEdda); + } } diff --git a/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/provider/agent/AwsCertificateManagerCachingAgentSpec.groovy b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/provider/agent/AwsCertificateManagerCachingAgentSpec.groovy new file mode 100644 index 00000000000..2e17c1e971e --- /dev/null +++ b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/provider/agent/AwsCertificateManagerCachingAgentSpec.groovy @@ -0,0 +1,210 @@ +/* + * Copyright 2021 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * This file 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.aws.provider.agent + +import com.amazonaws.services.certificatemanager.AWSCertificateManager +import com.amazonaws.services.certificatemanager.model.AWSCertificateManagerException +import com.amazonaws.services.certificatemanager.model.CertificateDetail +import com.amazonaws.services.certificatemanager.model.CertificateSummary +import com.amazonaws.services.certificatemanager.model.DescribeCertificateRequest +import com.amazonaws.services.certificatemanager.model.DescribeCertificateResult +import com.amazonaws.services.certificatemanager.model.ListCertificatesRequest +import com.amazonaws.services.certificatemanager.model.ListCertificatesResult +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.awsobjectmapper.AmazonObjectMapperConfigurer +import com.netflix.spectator.api.Id +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.cats.cache.CacheData +import com.netflix.spinnaker.cats.provider.ProviderCache +import com.netflix.spinnaker.clouddriver.aws.cache.Keys +import com.netflix.spinnaker.clouddriver.aws.security.AmazonClientProvider +import com.netflix.spinnaker.clouddriver.aws.security.NetflixAmazonCredentials +import spock.lang.Specification +import spock.lang.Subject + +import java.time.Duration +import java.time.Instant + +import static com.netflix.spinnaker.clouddriver.aws.cache.Keys.Namespace.CERTIFICATES + +class AwsCertificateManagerCachingAgentSpec extends Specification { + static final String account = "test-account" + static final String accountId = "123456789012" + static final String region = "us-west-2" + + AWSCertificateManager certificateManager = Mock(AWSCertificateManager) + + NetflixAmazonCredentials creds = Stub(NetflixAmazonCredentials) { + getName() >> account + it.getAccountId() >> accountId + } + + AmazonClientProvider amazonClientProvider = Stub(AmazonClientProvider) { + getAwsCertificateManager(creds, region) >> certificateManager + } + + ProviderCache providerCache = Mock(ProviderCache) + + ObjectMapper amazonObjectMapper = new AmazonObjectMapperConfigurer().createConfigured() + + Id exceptionGauge = Mock(Id) + + Registry registry = Stub(Registry) { + createId("aws.certificateCache.errors", + "account", account, + "account_id", accountId, + "region", region) >> exceptionGauge + } + + String certificateArn1 = "arn:aws:acm:::certificate/certificate1" + CertificateDetail certificateDetail1 = new CertificateDetail() + .withCertificateArn(certificateArn1) + .withDomainName("www.domain.example.com") + .withNotAfter(new Date()) + .withCreatedAt(new Date()) + CertificateSummary certificateSummary1 = new CertificateSummary() + .withCertificateArn(certificateArn1) + DescribeCertificateResult certificateResult1 = new DescribeCertificateResult() + .withCertificate(certificateDetail1) + + String certificateArn2 = "arn:aws:acm:::certificate/certificate2" + CertificateDetail certificateDetail2 = new CertificateDetail() + .withCertificateArn(certificateArn2) + .withDomainName("*.example.com") + .withNotAfter(new Date()) + .withCreatedAt(new Date()) + CertificateSummary certificateSummary2 = new CertificateSummary() + .withCertificateArn(certificateArn2) + DescribeCertificateResult certificateResult2 = new DescribeCertificateResult() + .withCertificate(certificateDetail2) + + @Subject + AwsCertificateManagerCachingAgent agent = new AwsCertificateManagerCachingAgent( + amazonClientProvider, creds, region, amazonObjectMapper, registry) + + void setup() { + agent.lastFailure = null + } + + void "loadData retrieves all ACM certificates from a given account and region"() { + when: + def result = agent.loadData(providerCache) + + then: + 1 * certificateManager.listCertificates(new ListCertificatesRequest()) >> new ListCertificatesResult() + .withCertificateSummaryList([certificateSummary1]) + .withNextToken("token") + 1 * certificateManager.listCertificates(new ListCertificatesRequest().withNextToken("token")) >> + new ListCertificatesResult().withCertificateSummaryList([certificateSummary2]) + + 1 * certificateManager.describeCertificate(new DescribeCertificateRequest() + .withCertificateArn(certificateArn1)) >> certificateResult1 + 1 * certificateManager.describeCertificate(new DescribeCertificateRequest() + .withCertificateArn(certificateArn2)) >> certificateResult2 + + with(result.cacheResults.get(CERTIFICATES.ns)) { Collection cd -> + cd.size() == 2 + cd.find { + it.id == Keys.getCertificateKey("certificate1", region, account, "acm") + }.attributes == [ + "expiration" : certificateDetail1.notAfter.toTimestamp().time, + "path" : "", + "serverCertificateId" : "certificate1", + "serverCertificateName": certificateDetail1.domainName, + "arn" : certificateDetail1.certificateArn, + "uploadDate" : certificateDetail1.createdAt.toTimestamp().time + ] + cd.find { + it.id == Keys.getCertificateKey("certificate2", region, account, "acm") + }.attributes == [ + "expiration" : certificateDetail2.notAfter.toTimestamp().time, + "path" : "", + "serverCertificateId" : "certificate2", + "serverCertificateName": certificateDetail2.domainName, + "arn" : certificateDetail2.certificateArn, + "uploadDate" : certificateDetail2.createdAt.toTimestamp().time + ] + } + } + + void "loadData returns an empty list if the lastFailure is non-null and the retry delay has not yet passed"() { + given: + agent.lastFailure = Instant.now() + + when: + def result = agent.loadData(providerCache) + + then: + result.cacheResults == [:] + 0 * certificateManager._ + } + + void "loadData retrieves all ACM certificates in a given account and region if lastFailure is non-null and the retry delay has passed"() { + given: + agent.lastFailure = Instant.now() - AwsCertificateManagerCachingAgent.RETRY_DELAY - Duration.ofMillis(1) + + when: + def result = agent.loadData(providerCache) + + then: + 1 * certificateManager.listCertificates(new ListCertificatesRequest()) >> new ListCertificatesResult() + .withCertificateSummaryList([certificateSummary1]) + + 1 * certificateManager.describeCertificate(new DescribeCertificateRequest() + .withCertificateArn(certificateArn1)) >> certificateResult1 + + result.cacheResults.get(CERTIFICATES.ns).size() == 1 + } + + void "loadData stops calling listCertificates if it encounters an exception"() { + when: + def result = agent.loadData(providerCache) + + then: + 1 * certificateManager.listCertificates(new ListCertificatesRequest()) >> new ListCertificatesResult() + .withCertificateSummaryList([certificateSummary1]) + .withNextToken("token") + 1 * certificateManager.listCertificates(new ListCertificatesRequest().withNextToken("token")) >> { + throw new AWSCertificateManagerException("boom!") + } + + 1 * certificateManager.describeCertificate(new DescribeCertificateRequest() + .withCertificateArn(certificateArn1)) >> certificateResult1 + + result.cacheResults.get(CERTIFICATES.ns).size() == 1 + } + + void "loadData skips any certificate for which the DescribeCertificate request fails"() { + when: + def result = agent.loadData(providerCache) + + then: + 1 * certificateManager.listCertificates(new ListCertificatesRequest()) >> new ListCertificatesResult() + .withCertificateSummaryList([certificateSummary1, certificateSummary2]) + + 1 * certificateManager.describeCertificate(new DescribeCertificateRequest() + .withCertificateArn(certificateArn1)) >> { throw new AWSCertificateManagerException("boom!") } + 1 * certificateManager.describeCertificate(new DescribeCertificateRequest() + .withCertificateArn(certificateArn2)) >> certificateResult2 + + with(result.cacheResults.get(CERTIFICATES.ns)) { Collection cd -> + cd.size() == 1 + cd.find { + it.id == Keys.getCertificateKey("certificate2", region, account, "acm") + } + } + } +} diff --git a/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/security/AmazonCredentialsLifecycleHandlerSpec.groovy b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/security/AmazonCredentialsLifecycleHandlerSpec.groovy index 52a20a14fd9..98b420195d2 100644 --- a/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/security/AmazonCredentialsLifecycleHandlerSpec.groovy +++ b/clouddriver-aws/src/test/groovy/com/netflix/spinnaker/clouddriver/aws/security/AmazonCredentialsLifecycleHandlerSpec.groovy @@ -123,7 +123,7 @@ class AmazonCredentialsLifecycleHandlerSpec extends Specification { then: awsInfrastructureProvider.getAgents().size() == 12 - awsProvider.getAgents().size() == 22 + awsProvider.getAgents().size() == 24 handler.publicRegions.size() == 2 handler.awsInfraRegions.size() == 2 handler.reservationReportCachingAgentScheduled