From 017788b94f66631d12a0512aca1ce76b13d02034 Mon Sep 17 00:00:00 2001 From: Hahn Date: Fri, 30 May 2025 10:57:55 -0500 Subject: [PATCH 1/5] Add support for AWS SDK v2 --- build.gradle | 4 +- .../PropertyBasedClientConfigConstants.java | 2 + eureka-core/build.gradle | 7 + .../eureka/DefaultEurekaServerConfig.java | 6 + .../netflix/eureka/EurekaServerConfig.java | 23 + .../com/netflix/eureka/aws/AwsAsgUtilV2.java | 539 ++++++++++++++++++ .../netflix/eureka/aws/AwsBinderDelegate.java | 13 +- .../com/netflix/eureka/aws/EIPManagerV2.java | 449 +++++++++++++++ .../aws/ElasticNetworkInterfaceBinderV2.java | 323 +++++++++++ .../netflix/eureka/aws/Route53BinderV2.java | 360 ++++++++++++ .../netflix/eureka/aws/EIPManagerV2Test.java | 80 +++ 11 files changed, 1801 insertions(+), 5 deletions(-) create mode 100644 eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java create mode 100644 eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java create mode 100644 eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java create mode 100644 eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java create mode 100644 eureka-core/src/test/java/com/netflix/eureka/aws/EIPManagerV2Test.java diff --git a/build.gradle b/build.gradle index 81d0ebcf9d..6cee4635fc 100644 --- a/build.gradle +++ b/build.gradle @@ -31,10 +31,10 @@ if (JavaVersion.current().isJava8Compatible()) { } allprojects { - ext { +ext { githubProjectName = 'eureka' - awsVersion = '1.11.277' + awsV2Version = '2.31.37' servletVersion = '5.0.0' jettisonVersion = '1.5.4' apacheHttpClientVersion = '4.5.3' diff --git a/eureka-client/src/main/java/com/netflix/discovery/PropertyBasedClientConfigConstants.java b/eureka-client/src/main/java/com/netflix/discovery/PropertyBasedClientConfigConstants.java index 4186e5a4e0..5b42643286 100644 --- a/eureka-client/src/main/java/com/netflix/discovery/PropertyBasedClientConfigConstants.java +++ b/eureka-client/src/main/java/com/netflix/discovery/PropertyBasedClientConfigConstants.java @@ -11,6 +11,8 @@ final class PropertyBasedClientConfigConstants { // NOTE: all keys are before any prefixes are applied static final String CLIENT_REGION_KEY = "region"; + static final String CLIENT_USE_AWS_V2_KEY = "useAwsSdkV2"; + static final String REGISTRATION_ENABLED_KEY = "registration.enabled"; static final String FETCH_REGISTRY_ENABLED_KEY = "shouldFetchRegistry"; static final String SHOULD_ENFORCE_FETCH_REGISTRY_AT_INIT_KEY = "shouldEnforceFetchRegistryAtInit"; diff --git a/eureka-core/build.gradle b/eureka-core/build.gradle index 8273422860..d782ae5025 100644 --- a/eureka-core/build.gradle +++ b/eureka-core/build.gradle @@ -8,6 +8,12 @@ dependencies { api "com.amazonaws:aws-java-sdk-autoscaling:${awsVersion}" api "com.amazonaws:aws-java-sdk-sts:${awsVersion}" api "com.amazonaws:aws-java-sdk-route53:${awsVersion}" + api "software.amazon.awssdk:ec2:${awsV2Version}" + api "software.amazon.awssdk:autoscaling:${awsV2Version}" + api "software.amazon.awssdk:auth:${awsV2Version}" + api "software.amazon.awssdk:route53:${awsV2Version}" + api "software.amazon.awssdk:sesv2:${awsV2Version}" + api "software.amazon.awssdk:sts:${awsV2Version}" api "jakarta.servlet:jakarta.servlet-api:${servletVersion}" api 'jakarta.inject:jakarta.inject-api:2.0.1' api 'com.thoughtworks.xstream:xstream:1.4.21' @@ -26,4 +32,5 @@ dependencies { testImplementation "org.mockito:mockito-core:${mockitoVersion}" testImplementation "org.assertj:assertj-core:3.24.2" testRuntimeOnly 'org.slf4j:slf4j-simple:1.7.10' + } diff --git a/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java b/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java index 9ef6db6f13..18d4cd315a 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java +++ b/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java @@ -156,6 +156,12 @@ public String getAWSSecretKey() { } } + @Override + public boolean isUseAwsSdkV2(){ + return configInstance.getBooleanProperty(namespace + "useAwsSdkV2Key", false).get(); + } + + /* * (non-Javadoc) * diff --git a/eureka-core/src/main/java/com/netflix/eureka/EurekaServerConfig.java b/eureka-core/src/main/java/com/netflix/eureka/EurekaServerConfig.java index cf8f9cc621..d071adcecf 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/EurekaServerConfig.java +++ b/eureka-core/src/main/java/com/netflix/eureka/EurekaServerConfig.java @@ -57,6 +57,29 @@ public interface EurekaServerConfig { */ String getAWSSecretKey(); + /** + * Defaults to false for back compatability. + * If set to true, the client will need to add the jars to their gradle: + * api "com.amazonaws:aws-java-sdk-core:${awsV2Version}" + * api "com.amazonaws:aws-java-sdk-ec2:${awsV2Version}" + * api "com.amazonaws:aws-java-sdk-autoscaling:${awsV2Version}" + * api "com.amazonaws:aws-java-sdk-sts:${awsV2Version}" + * api "com.amazonaws:aws-java-sdk-route53:${awsV2Version}" + * api "software.amazon.awssdk:ec2:${awsV2Version}" + * api "software.amazon.awssdk:autoscaling:${awsV2Version}" + * api "software.amazon.awssdk:auth:${awsV2Version}" + * api "software.amazon.awssdk:route53:${awsV2Version}" + * api "software.amazon.awssdk:sesv2:${awsV2Version}" + * api "software.amazon.awssdk:sts:${awsV2Version}" + * + * And exclude com.amazonaws + * + * @return + */ + default boolean isUseAwsSdkV2() { + return false; + } + /** * Gets the number of times the server should try to bind to the candidate * EIP. diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java new file mode 100644 index 0000000000..dad8b41ca2 --- /dev/null +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java @@ -0,0 +1,539 @@ +/* + * This class is intended to replace AwsAsgUtil in the next major version of eureka-core. + * For now the code is split allowing optional use of aws jdk version 2 + */ + + +/* + * Copyright 2012 Netflix, 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.eureka.aws; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.netflix.appinfo.AmazonInfo; +import com.netflix.appinfo.AmazonInfo.MetaDataKey; +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.DataCenterInfo; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClientConfig; +import com.netflix.discovery.shared.Application; +import com.netflix.discovery.shared.Applications; +import com.netflix.discovery.util.SpectatorUtil; +import com.netflix.eureka.EurekaServerConfig; +import com.netflix.eureka.registry.InstanceRegistry; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.autoscaling.AutoScalingClient; +import software.amazon.awssdk.services.autoscaling.AutoScalingClientBuilder; +import software.amazon.awssdk.services.autoscaling.model.AutoScalingGroup; +import software.amazon.awssdk.services.autoscaling.model.DescribeAutoScalingGroupsRequest; +import software.amazon.awssdk.services.autoscaling.model.DescribeAutoScalingGroupsResponse; +import software.amazon.awssdk.services.autoscaling.model.SuspendedProcess; +import software.amazon.awssdk.services.sts.StsClient; +import software.amazon.awssdk.services.sts.StsClientBuilder; +import software.amazon.awssdk.services.sts.model.AssumeRoleRequest; +import software.amazon.awssdk.services.sts.model.AssumeRoleResponse; +import software.amazon.awssdk.services.sts.model.Credentials; + +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; + +/** + * A utility class for querying and updating information about amazon + * autoscaling groups using the AWS APIs. + * + * @author Karthik Ranganathan + * + */ +@Singleton +public class AwsAsgUtilV2 implements AsgClient { + private static final Logger logger = LoggerFactory.getLogger(AwsAsgUtil.class); + + private static final String PROP_ADD_TO_LOAD_BALANCER = "AddToLoadBalancer"; + + private static final String accountId = getAccountId(); + + private Map stsCredentials = new HashMap<>(); + + private final ExecutorService cacheReloadExecutor = new ThreadPoolExecutor( + 1, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(), + new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Eureka-AWS-isASGEnabled"); + thread.setDaemon(true); + return thread; + } + }); + + private ListeningExecutorService listeningCacheReloadExecutor = MoreExecutors.listeningDecorator(cacheReloadExecutor); + + // Cache for the AWS ASG information + private final Timer timer = new Timer("Eureka-ASGCacheRefresh", true); + private final com.netflix.spectator.api.Timer loadASGInfoTimer = SpectatorUtil.timer("Eureka-loadASGInfo", AwsAsgUtil.class); + + private final EurekaServerConfig serverConfig; + private final EurekaClientConfig clientConfig; + private final InstanceRegistry registry; + private final LoadingCache asgCache; + private final AutoScalingClient awsClient; + + @Inject + public AwsAsgUtilV2(EurekaServerConfig serverConfig, + EurekaClientConfig clientConfig, + InstanceRegistry registry) { + this.serverConfig = serverConfig; + this.clientConfig = clientConfig; + this.registry = registry; + this.asgCache = CacheBuilder + .newBuilder().initialCapacity(500) + .expireAfterAccess(serverConfig.getASGCacheExpiryTimeoutMs(), TimeUnit.MILLISECONDS) + .build(new CacheLoader() { + @Override + public Boolean load(CacheKey key) throws Exception { + return isASGEnabledinAWS(key.asgAccountId, key.asgName); + } + @Override + public ListenableFuture reload(final CacheKey key, Boolean oldValue) throws Exception { + return listeningCacheReloadExecutor.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return load(key); + } + }); + } + }); + + this.awsClient = getAmazonAutoScalingClient(); + + + // Cache for the AWS ASG information + Timer timer = new Timer("Eureka-ASGCacheRefresh", true); + timer.schedule(getASGUpdateTask(), + serverConfig.getASGUpdateIntervalMs(), + serverConfig.getASGUpdateIntervalMs()); + SpectatorUtil.monitoredValue("numOfElementsinASGCache", + this, AwsAsgUtilV2::getNumberofElementsinASGCache); + SpectatorUtil.monitoredValue("numOfASGQueries", + this, AwsAsgUtilV2::getNumberofASGQueries); + SpectatorUtil.monitoredValue("numOfASGQueryFailures", + this, AwsAsgUtilV2::getNumberofASGQueryFailures); + } + + /** + * Return the status of the ASG whether is enabled or disabled for service. + * The value is picked up from the cache except the very first time. + * + * @param instanceInfo the instanceInfo for the lookup + * @return true if enabled, false otherwise + */ + public boolean isASGEnabled(InstanceInfo instanceInfo) { + CacheKey cacheKey = new CacheKey(getAccountId(instanceInfo, accountId), instanceInfo.getASGName()); + Boolean result = asgCache.getIfPresent(cacheKey); + if (result != null) { + return result; + } else { + if (!serverConfig.shouldUseAwsAsgApi()) { + // Disabled, cached values (if any) are still being returned if the caller makes + // a decision to call the disabled client during some sort of transitioning + // period, but no new values will be fetched while disabled. + + logger.info(("'{}' is not cached at the moment and won't be fetched because querying AWS ASGs " + + "has been disabled via the config, returning the fallback value."), + cacheKey); + + return true; + } + + logger.info("Cache value for asg {} does not exist yet, async refreshing.", cacheKey.asgName); + // Only do an async refresh if it does not yet exist. Do this to refrain from calling aws api too much + asgCache.refresh(cacheKey); + return true; + } + } + + /** + * Sets the status of the ASG. + * + * @param asgName The name of the ASG + * @param enabled true to enable, false to disable + */ + public void setStatus(String asgName, boolean enabled) { + String asgAccountId = getASGAccount(asgName); + asgCache.put(new CacheKey(asgAccountId, asgName), enabled); + } + + /** + * Check if the ASG is disabled. The amazon flag "AddToLoadBalancer" is + * queried to figure out if it is or not. + * + * @param asgName + * - The name of the ASG for which the status needs to be queried + * @return - true if the ASG is disabled, false otherwise + */ + private boolean isAddToLoadBalancerSuspended(String asgAccountId, String asgName) { + AutoScalingGroup asg; + if(asgAccountId == null || asgAccountId.equals(accountId)) { + asg = retrieveAutoScalingGroup(asgName); + } else { + asg = retrieveAutoScalingGroupCrossAccount(asgAccountId, asgName); + } + if (asg == null) { + logger.warn("The ASG information for {} could not be found. So returning false.", asgName); + return false; + } + return isAddToLoadBalancerSuspended(asg); + } + + /** + * Checks if the load balancer addition is disabled or not. + * + * @param asg + * - The ASG object for which the status needs to be checked + * @return - true, if the load balancer addition is suspended, false + * otherwise. + */ + private boolean isAddToLoadBalancerSuspended(AutoScalingGroup asg) { + List suspendedProcesses = asg.suspendedProcesses(); + for (SuspendedProcess process : suspendedProcesses) { + if (PROP_ADD_TO_LOAD_BALANCER.equals(process.processName())) { + return true; + } + } + return false; + } + + + /** + * Queries AWS to get the autoscaling information given the asgName. + * + * @param asgName + * - The name of the ASG. + * @return - The auto scaling group information. + */ + private AutoScalingGroup retrieveAutoScalingGroup(String asgName) { + if (asgName == null || asgName.isEmpty()) { + logger.warn("null asgName specified, not attempting to retrieve AutoScalingGroup from AWS"); + return null; + } + // You can pass one name or a list of names in the request + DescribeAutoScalingGroupsRequest request = DescribeAutoScalingGroupsRequest.builder() + .autoScalingGroupNames(asgName) + .build(); + DescribeAutoScalingGroupsResponse result = awsClient.describeAutoScalingGroups(request); + List asgs = result.autoScalingGroups(); + if (asgs.isEmpty()) { + return null; + } else { + return asgs.get(0); + } + } + + private Credentials initializeStsSession(String asgAccount) { + String region = clientConfig.getRegion(); + StsClientBuilder stsBuilder = StsClient.builder() + .credentialsProvider(InstanceProfileCredentialsProvider.create()) + //setting the region based on config, letting jdk resolve endponit + .region(Region.of(region)); + + + StsClient sts = stsBuilder.build(); + + String roleName = serverConfig.getListAutoScalingGroupsRoleName(); + String roleArn = "arn:aws:iam::" + asgAccount + ":role/" + roleName; + + AssumeRoleRequest assumeRoleRequest = AssumeRoleRequest.builder() + .roleArn(roleArn) + .roleSessionName("sts-session-" + asgAccount) + .build(); + + AssumeRoleResponse assumeRoleResponse = sts.assumeRole(assumeRoleRequest); + + return assumeRoleResponse.credentials(); + } + + private AutoScalingGroup retrieveAutoScalingGroupCrossAccount(String asgAccount, String asgName) { + logger.debug("Getting cross account ASG for asgName: {}, asgAccount: {}", asgName, asgAccount); + + Credentials credentials = stsCredentials.get(asgAccount); + + if (credentials == null || credentials.expiration().toEpochMilli() < System.currentTimeMillis() + 1000) { + stsCredentials.put(asgAccount, initializeStsSession(asgAccount)); + credentials = stsCredentials.get(asgAccount); + } + + AwsSessionCredentials awsSessionCredentials = AwsSessionCredentials.create( + credentials.accessKeyId(), + credentials.secretAccessKey(), + credentials.sessionToken() + ); + + AutoScalingClientBuilder autoScalingClientBuilder = AutoScalingClient.builder() + .credentialsProvider(StaticCredentialsProvider.create(awsSessionCredentials)) + //setting region based on config, letting sdk resolve endpoint + .region(Region.of(clientConfig.getRegion())) + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .apiCallAttemptTimeout(Duration.ofMillis(serverConfig.getASGQueryTimeoutMs())) + .build()); + AutoScalingClient autoScalingClient = autoScalingClientBuilder.build(); + + DescribeAutoScalingGroupsRequest request = DescribeAutoScalingGroupsRequest.builder() + .autoScalingGroupNames(asgName) + .build(); + DescribeAutoScalingGroupsResponse result = autoScalingClient.describeAutoScalingGroups(request); + List asgs = result.autoScalingGroups(); + if (asgs.isEmpty()) { + return null; + } else { + return asgs.get(0); + } + } + + /** + * Queries AWS to see if the load balancer flag is suspended. + * + * @param asgAccountid the accountId this asg resides in, if applicable (null will use the default accountId) + * @param asgName the name of the asg + * @return true, if the load balancer flag is not suspended, false otherwise. + */ + private Boolean isASGEnabledinAWS(String asgAccountid, String asgName) { + try { + final long t = SpectatorUtil.time(loadASGInfoTimer); + boolean returnValue = !isAddToLoadBalancerSuspended(asgAccountid, asgName); + SpectatorUtil.record(loadASGInfoTimer, t); + return returnValue; + } catch (Throwable e) { + logger.error("Could not get ASG information from AWS: ", e); + } + return Boolean.TRUE; + } + + /** + * Gets the number of elements in the ASG cache. + * + * @return the long value representing the number of elements in the ASG + * cache. + */ + public long getNumberofElementsinASGCache() { + return asgCache.size(); + } + + /** + * Gets the number of ASG queries done in the period. + * + * @return the long value representing the number of ASG queries done in the + * period. + */ + public long getNumberofASGQueries() { + return asgCache.stats().loadCount(); + } + + /** + * Gets the number of ASG queries that failed because of some reason. + * + * @return the long value representing the number of ASG queries that failed + * because of some reason. + */ + public long getNumberofASGQueryFailures() { + return asgCache.stats().loadExceptionCount(); + } + + /** + * Gets the task that updates the ASG information periodically. + * + * @return TimerTask that updates the ASG information periodically. + */ + private TimerTask getASGUpdateTask() { + return new TimerTask() { + + @Override + public void run() { + try { + if (!serverConfig.shouldUseAwsAsgApi()) { + // Disabled via the config, no-op. + return; + } + + // First get the active ASG names + Set cacheKeys = getCacheKeys(); + if (logger.isDebugEnabled()) { + logger.debug("Trying to refresh the keys for {}", Arrays.toString(cacheKeys.toArray())); + } + for (CacheKey key : cacheKeys) { + try { + asgCache.refresh(key); + } catch (Throwable e) { + logger.error("Error updating the ASG cache for {}", key, e); + } + + } + + } catch (Throwable e) { + logger.error("Error updating the ASG cache", e); + } + + } + + }; + } + + /** + * Get the cacheKeys of all the ASG to which query AWS for. + * + *

+ * The names are obtained from the {@link com.netflix.eureka.registry.InstanceRegistry} which is then + * used for querying the AWS. + *

+ * + * @return the set of ASG cacheKeys (asgName + accountId). + */ + private Set getCacheKeys() { + Set cacheKeys = new HashSet<>(); + Applications apps = registry.getApplicationsFromLocalRegionOnly(); + for (Application app : apps.getRegisteredApplications()) { + for (InstanceInfo instanceInfo : app.getInstances()) { + String localAccountId = getAccountId(instanceInfo, accountId); + String asgName = instanceInfo.getASGName(); + if (asgName != null) { + CacheKey key = new CacheKey(localAccountId, asgName); + cacheKeys.add(key); + } + } + } + + return cacheKeys; + } + + /** + * Get the AWS account id where an ASG is created. + * Warning: This is expensive as it loops through all instances currently registered. + * + * @param asgName The name of the ASG + * @return the account id + */ + private String getASGAccount(String asgName) { + Applications apps = registry.getApplicationsFromLocalRegionOnly(); + + for (Application app : apps.getRegisteredApplications()) { + for (InstanceInfo instanceInfo : app.getInstances()) { + String thisAsgName = instanceInfo.getASGName(); + if (thisAsgName != null && thisAsgName.equals(asgName)) { + String localAccountId = getAccountId(instanceInfo, null); + if (localAccountId != null) { + return localAccountId; + } + } + } + } + + logger.info("Couldn't get the ASG account for {}, using the default accountId instead", asgName); + return accountId; + } + + private String getAccountId(InstanceInfo instanceInfo, String fallbackId) { + String localAccountId = null; + + DataCenterInfo dataCenterInfo = instanceInfo.getDataCenterInfo(); + if (dataCenterInfo instanceof AmazonInfo) { + localAccountId = ((AmazonInfo) dataCenterInfo).get(MetaDataKey.accountId); + } + + return localAccountId == null ? fallbackId : localAccountId; + } + + private AutoScalingClient getAmazonAutoScalingClient() { + String awsAccessId = serverConfig.getAWSAccessId(); + String awsSecretKey = serverConfig.getAWSSecretKey(); + + + AutoScalingClientBuilder builder = AutoScalingClient.builder() + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .apiCallAttemptTimeout(Duration.ofMillis(serverConfig.getASGQueryTimeoutMs())) + .build()) + .region(Region.of(clientConfig.getRegion())); + + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); + builder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); + } else { + builder.credentialsProvider(InstanceProfileCredentialsProvider.create()); + } + + return builder.build(); + } + + + private static String getAccountId() { + InstanceInfo myInfo = ApplicationInfoManager.getInstance().getInfo(); + return ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.accountId); + } + + private static class CacheKey { + final String asgAccountId; + final String asgName; + + CacheKey(String asgAccountId, String asgName) { + this.asgAccountId = asgAccountId; + this.asgName = asgName; + } + + @Override + public String toString() { + return "CacheKey{" + + "asgName='" + asgName + '\'' + + ", asgAccountId='" + asgAccountId + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CacheKey)) return false; + + CacheKey cacheKey = (CacheKey) o; + + if (asgAccountId != null ? !asgAccountId.equals(cacheKey.asgAccountId) : cacheKey.asgAccountId != null) + return false; + if (asgName != null ? !asgName.equals(cacheKey.asgName) : cacheKey.asgName != null) return false; + + return true; + } + + @Override + public int hashCode() { + int result = asgName != null ? asgName.hashCode() : 0; + result = 31 * result + (asgAccountId != null ? asgAccountId.hashCode() : 0); + return result; + } + } +} diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java index ae728f1c91..f3bed7a2db 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java @@ -21,15 +21,22 @@ public AwsBinderDelegate(EurekaServerConfig serverConfig, PeerAwareInstanceRegistry registry, ApplicationInfoManager applicationInfoManager) { AwsBindingStrategy bindingStrategy = serverConfig.getBindingStrategy(); + boolean useAwsSdkV2 = serverConfig.isUseAwsSdkV2(); switch (bindingStrategy) { case ROUTE53: - delegate = new Route53Binder(serverConfig, clientConfig, applicationInfoManager); + delegate = useAwsSdkV2 ? + new Route53BinderV2(serverConfig, clientConfig, applicationInfoManager) + : new Route53Binder(serverConfig, clientConfig, applicationInfoManager); break; case EIP: - delegate = new EIPManager(serverConfig, clientConfig, registry, applicationInfoManager); + delegate = useAwsSdkV2 ? + new EIPManagerV2(serverConfig, clientConfig, registry, applicationInfoManager) : + new EIPManager(serverConfig, clientConfig, registry, applicationInfoManager); break; case ENI: - delegate = new ElasticNetworkInterfaceBinder(serverConfig, clientConfig, registry, applicationInfoManager); + delegate = useAwsSdkV2 ? + new ElasticNetworkInterfaceBinderV2(serverConfig, clientConfig, registry, applicationInfoManager) + : new ElasticNetworkInterfaceBinder(serverConfig, clientConfig, registry, applicationInfoManager); break; default: throw new IllegalArgumentException("Unexpected BindingStrategy " + bindingStrategy); diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java new file mode 100644 index 0000000000..c6d8e55c03 --- /dev/null +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java @@ -0,0 +1,449 @@ +/* + * This class is intended to replace EIPManager in the next major version of eureka-core. + * For now the code is split allowing optional use of aws jdk version 2 + * + * Compare to EIPManager to understand differences. + */ + +/* + * Copyright 2012 Netflix, 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.eureka.aws; + +import com.netflix.appinfo.AmazonInfo; +import com.netflix.appinfo.AmazonInfo.MetaDataKey; +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.DataCenterInfo.Name; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClientConfig; +import com.netflix.discovery.endpoint.EndpointUtils; +import com.netflix.eureka.EurekaServerConfig; +import com.netflix.eureka.registry.PeerAwareInstanceRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.ec2.Ec2Client; +import software.amazon.awssdk.services.ec2.Ec2ClientBuilder; +import software.amazon.awssdk.services.ec2.model.*; + +import java.net.URI; +import java.util.*; + + +/** + * An AWS specific elastic ip binding utility for binding eureka + * servers for a well known IP address. + * + *

+ * Eureka clients talk to Eureka servers bound with well known + * IP addresses since that is the most reliable mechanism to + * discover the Eureka servers. When Eureka servers come up they bind + * themselves to a well known elastic ip + *

+ * + *

+ * This binding mechanism gravitates towards one eureka server per zone for + * resilience. At least one elastic ip should be slotted for each eureka server in + * a zone. If more than eureka server is launched per zone and there are not + * enough elastic ips slotted, the server tries to pick a free EIP slotted for other + * zones and if it still cannot find a free EIP, waits and keeps trying. + *

+ * + * @author Karthik Ranganathan, Greg Kim + * + */ +@Singleton +public class EIPManagerV2 implements AwsBinder { + private static final Logger logger = LoggerFactory.getLogger(EIPManager.class); + + private static final String US_EAST_1 = "us-east-1"; + private static final int EIP_BIND_SLEEP_TIME_MS = 1000; + private static final Timer timer = new Timer("Eureka-EIPBinder", true); + + private final EurekaServerConfig serverConfig; + private final EurekaClientConfig clientConfig; + private final PeerAwareInstanceRegistry registry; + private final ApplicationInfoManager applicationInfoManager; + + @Inject + public EIPManagerV2(EurekaServerConfig serverConfig, + EurekaClientConfig clientConfig, + PeerAwareInstanceRegistry registry, + ApplicationInfoManager applicationInfoManager) { + this.serverConfig = serverConfig; + this.clientConfig = clientConfig; + this.registry = registry; + this.applicationInfoManager = applicationInfoManager; + } + + @PostConstruct + public void start() { + try { + handleEIPBinding(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @PreDestroy + public void shutdown() { + timer.cancel(); + for (int i = 0; i < serverConfig.getEIPBindRebindRetries(); i++) { + try { + unbindEIP(); + break; + } catch (Exception e) { + logger.warn("Cannot unbind the EIP from the instance"); + try { + Thread.sleep(1000); + } catch (InterruptedException e1) { + throw new RuntimeException(e1); + } + } + } + } + + + /** + * Handles EIP binding process in AWS Cloud. + * + * @throws InterruptedException + */ + private void handleEIPBinding() throws InterruptedException { + int retries = serverConfig.getEIPBindRebindRetries(); + // Bind to EIP if needed + for (int i = 0; i < retries; i++) { + try { + if (isEIPBound()) { + break; + } else { + bindEIP(); + } + } catch (Throwable e) { + logger.error("Cannot bind to EIP", e); + Thread.sleep(EIP_BIND_SLEEP_TIME_MS); + } + } + // Schedule a timer which periodically checks for EIP binding. + timer.schedule(new EIPBindingTask(), serverConfig.getEIPBindingRetryIntervalMsWhenUnbound()); + } + + /** + * Checks if an EIP is already bound to the instance. + * @return true if an EIP is bound, false otherwise + */ + public boolean isEIPBound() { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + String myInstanceId = ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.instanceId); + String myZone = ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.availabilityZone); + String myPublicIP = ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.publicIpv4); + + Collection candidateEIPs = getCandidateEIPs(myInstanceId, myZone); + for (String eipEntry : candidateEIPs) { + if (eipEntry.equals(myPublicIP)) { + logger.info("My instance {} seems to be already associated with the public ip {}", + myInstanceId, myPublicIP); + return true; + } + } + return false; + } + + /** + * Checks if an EIP is bound and optionally binds the EIP. + * + * The list of EIPs are arranged with the EIPs allocated in the zone first + * followed by other EIPs. + * + * If an EIP is already bound to this instance this method simply returns. Otherwise, this method tries to find + * an unused EIP based on information from AWS. If it cannot find any unused EIP this method, it will be retried + * for a specified interval. + * + * One of the following scenarios can happen here : + * + * 1) If the instance is already bound to an EIP as deemed by AWS, no action is taken. + * 2) If an EIP is already bound to another instance as deemed by AWS, that EIP is skipped. + * 3) If an EIP is not already bound to an instance and if this instance is not bound to an EIP, then + * the EIP is bound to this instance. + */ + public void bindEIP() { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + String myInstanceId = ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.instanceId); + String myZone = ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.availabilityZone); + + Collection candidateEIPs = getCandidateEIPs(myInstanceId, myZone); + + Ec2Client ec2Service = getEC2Service(); + boolean isMyinstanceAssociatedWithEIP = false; + Address selectedEIP = null; + + for (String eipEntry : candidateEIPs) { + try { + String associatedInstanceId; + + // Check with AWS, if this EIP is already been used by another instance + DescribeAddressesRequest describeAddressRequest = DescribeAddressesRequest.builder() + .publicIps(eipEntry) + .build(); + DescribeAddressesResponse result = ec2Service.describeAddresses(describeAddressRequest); + if (result.addresses() != null && !result.addresses().isEmpty()) { + Address eipAddress = result.addresses().get(0); + associatedInstanceId = eipAddress.instanceId(); + // This EIP is not used by any other instance, hence mark it for selection if it is not + // already marked. + if (associatedInstanceId == null || associatedInstanceId.isEmpty()) { + if (selectedEIP == null) { + selectedEIP = eipAddress; + } + } else if (isMyinstanceAssociatedWithEIP = associatedInstanceId.equals(myInstanceId)) { + // This EIP is associated with an instance, check if this is the same as the current instance. + // If it is the same, stop searching for an EIP as this instance is already associated with an EIP + selectedEIP = eipAddress; + break; + } else { + // The EIP is used by some other instance, hence skip it + logger.warn("The selected EIP {} is associated with another instance {} according to AWS," + + " hence skipping this", eipEntry, associatedInstanceId); + } + } + } catch (Throwable t) { + logger.error("Failed to bind elastic IP: {} to {}", eipEntry, myInstanceId, t); + } + } + if (selectedEIP != null) { + String publicIp = selectedEIP.publicIp(); + // Only bind if the EIP is not already associated + if (!isMyinstanceAssociatedWithEIP) { + + final AssociateAddressRequest.Builder associateAddressRequestBuilder = AssociateAddressRequest.builder() + .instanceId(myInstanceId); + + String domain = selectedEIP.domain().toString(); + if ("vpc".equals(domain)) { + associateAddressRequestBuilder.allocationId(selectedEIP.allocationId()); + } else { + associateAddressRequestBuilder.publicIp(publicIp); + } + + ec2Service.associateAddress(associateAddressRequestBuilder.build()); + logger.info("\n\n\nAssociated {} running in zone: {} to elastic IP: {}", myInstanceId, myZone, publicIp); + } + logger.info("My instance {} seems to be already associated with the EIP {}", myInstanceId, publicIp); + } else { + logger.info("No EIP is free to be associated with this instance. Candidate EIPs are: {}", candidateEIPs); + } + } + + + public void unbindEIP() throws Exception { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + String myPublicIP = null; + if (myInfo != null && myInfo.getDataCenterInfo().getName() == Name.Amazon) { + myPublicIP = ((AmazonInfo) myInfo.getDataCenterInfo()).get(MetaDataKey.publicIpv4); + if (myPublicIP == null) { + logger.info("Instance is not associated with an EIP. Will not try to unbind"); + return; + } + + try { + Ec2Client ec2Service = getEC2Service(); + final DescribeAddressesRequest describeAddressRequestBuilder = DescribeAddressesRequest.builder() + .publicIps(myPublicIP) + .build(); + DescribeAddressesResponse result = ec2Service.describeAddresses(describeAddressRequestBuilder); + if (result.addresses() != null && !result.addresses().isEmpty()) { + Address eipAddress = result.addresses().get(0); + DisassociateAddressRequest.Builder dissociateRequest = DisassociateAddressRequest.builder(); + String domain = eipAddress.domain().toString(); + if ("vpc".equals(domain)) { + dissociateRequest.associationId(eipAddress.associationId()); + } else { + dissociateRequest = dissociateRequest.publicIp(eipAddress.publicIp()); + } + + ec2Service.disassociateAddress(dissociateRequest.build()); + logger.info("Dissociated the EIP {} from this instance", myPublicIP); + } + } catch (Throwable e) { + throw new RuntimeException("Cannot dissociate address from this instance", e); + } + } + } + + + /** + * Get the list of EIPs in the order of preference depending on instance zone. + * + * @param myInstanceId + * the instance id for this instance + * @param myZone + * the zone where this instance is in + * @return Collection containing the list of available EIPs + */ + public Collection getCandidateEIPs(String myInstanceId, String myZone) { + + if (myZone == null) { + myZone = "us-east-1d"; + } + + Collection eipCandidates = clientConfig.shouldUseDnsForFetchingServiceUrls() + ? getEIPsForZoneFromDNS(myZone) + : getEIPsForZoneFromConfig(myZone); + + if (eipCandidates == null || eipCandidates.size() == 0) { + throw new RuntimeException("Could not get any elastic ips from the EIP pool for zone :" + myZone); + } + + return eipCandidates; + } + + /** + * Get the list of EIPs from the configuration. + * + * @param myZone + * - the zone in which the instance resides. + * @return collection of EIPs to choose from for binding. + */ + private Collection getEIPsForZoneFromConfig(String myZone) { + List ec2Urls = clientConfig.getEurekaServerServiceUrls(myZone); + return getEIPsFromServiceUrls(ec2Urls); + } + + /** + * Get the list of EIPs from the ec2 urls. + * + * @param ec2Urls + * the ec2urls for which the EIP needs to be obtained. + * @return collection of EIPs. + */ + private Collection getEIPsFromServiceUrls(List ec2Urls) { + List returnedUrls = new ArrayList<>(); + String region = clientConfig.getRegion(); + String regionPhrase = ""; + if (!US_EAST_1.equals(region)) { + regionPhrase = "." + region; + } + for (String cname : ec2Urls) { + int beginIndex = cname.indexOf("ec2-"); + + if (-1 < beginIndex) { + // CNAME contains "ec2-" + int endIndex = cname.indexOf(regionPhrase + ".compute"); + String eipStr = cname.substring(beginIndex + 4, endIndex); + String eip = eipStr.replaceAll("\\-", "."); + returnedUrls.add(eip); + } + + // Otherwise, if CNAME doesn't contain, do nothing. + // Handle case where there are no cnames containing "ec2-". Reasons include: + // Systems without public addresses - purely attached to corp lan via AWS Direct Connect + // Use of EC2 network adapters that are attached to an instance after startup + } + return returnedUrls; + } + + /** + * Get the list of EIPS from the DNS. + * + *

+ * This mechanism looks for the EIP pool in the zone the instance is in by + * looking up the DNS name {zone}.{region}.{domainName}. The + * zone is fetched from the {@link InstanceInfo} object;the region is picked + * up from the specified configuration + * {@link com.netflix.discovery.EurekaClientConfig#getRegion()};the domain name is picked up from + * the specified configuration {@link com.netflix.discovery.EurekaClientConfig#getEurekaServerDNSName()} + * with a "txt." prefix (see {@link com.netflix.discovery.endpoint.EndpointUtils + * #getZoneBasedDiscoveryUrlsFromRegion(com.netflix.discovery.EurekaClientConfig, String)}. + *

+ * + * @param myZone + * the zone where this instance exist in. + * @return the collection of EIPs that exist in the zone this instance is + * in. + */ + private Collection getEIPsForZoneFromDNS(String myZone) { + List ec2Urls = EndpointUtils.getServiceUrlsFromDNS( + clientConfig, + myZone, + true, + new EndpointUtils.InstanceInfoBasedUrlRandomizer(applicationInfoManager.getInfo()) + ); + return getEIPsFromServiceUrls(ec2Urls); + } + + /** + * Gets the EC2 service object to call AWS APIs. + * + * @return the EC2 service object to call AWS APIs. + */ + private Ec2Client getEC2Service() { + String awsAccessId = serverConfig.getAWSAccessId(); + String awsSecretKey = serverConfig.getAWSSecretKey(); + + final Ec2ClientBuilder ec2ServiceBuilder = Ec2Client.builder() + //we set region based on configuration, but let the sdk determine the endpoints + .region(Region.of(clientConfig.getRegion().trim().toLowerCase())); + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); + ec2ServiceBuilder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); + } else { + ec2ServiceBuilder.credentialsProvider(InstanceProfileCredentialsProvider.create()); + } + return ec2ServiceBuilder.build(); + } + + /** + * An EIP binding timer task which constantly polls for EIP in the + * same zone and binds it to itself.If the EIP is taken away for some + * reason, this task tries to get the EIP back. Hence it is advised to take + * one EIP assignment per instance in a zone. + */ + private class EIPBindingTask extends TimerTask { + @Override + public void run() { + boolean isEIPBound = false; + try { + isEIPBound = isEIPBound(); + // If the EIP is not bound, the registry could be stale. First sync up the registry from the + // neighboring node before trying to bind the EIP + if (!isEIPBound) { + registry.clearRegistry(); + int count = registry.syncUp(); + registry.openForTraffic(applicationInfoManager, count); + } else { + // An EIP is already bound + return; + } + bindEIP(); + } catch (Throwable e) { + logger.error("Could not bind to EIP", e); + } finally { + if (isEIPBound) { + timer.schedule(new EIPBindingTask(), serverConfig.getEIPBindingRetryIntervalMs()); + } else { + timer.schedule(new EIPBindingTask(), serverConfig.getEIPBindingRetryIntervalMsWhenUnbound()); + } + } + } + }; +} diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java new file mode 100644 index 0000000000..0857b0cbf0 --- /dev/null +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java @@ -0,0 +1,323 @@ +/* + * This class is intended to replace ElasticNetworkInterfaceBinder in the next major version of eureka-core. + * For now the code is split allowing optional use of aws jdk version 2 + * Compare to ElasticNetworkInterfaceBinder to understand changes. + */ + +package com.netflix.eureka.aws; + +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import com.google.common.collect.Ordering; +import com.google.common.net.InetAddresses; +import com.netflix.appinfo.AmazonInfo; +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClientConfig; +import com.netflix.discovery.endpoint.EndpointUtils; +import com.netflix.eureka.EurekaServerConfig; +import com.netflix.eureka.registry.PeerAwareInstanceRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.ec2.Ec2Client; +import software.amazon.awssdk.services.ec2.Ec2ClientBuilder; +import software.amazon.awssdk.services.ec2.model.*; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.*; + +/** + * Amazon ENI binder for instances. + * + * Candidate ENI's discovery is done using the same mechanism as Elastic ip binder, via dns records or service urls. + * + * The dns records and the service urls should use the ENI private dns or private ip + * + * Dns record examples + * txt.us-east-1.eureka="us-east-1a.eureka" "us-east-1b.eureka" + * txt.us-east-1a.eureka="ip-172-31-y-y.ec2.internal" + * txt.us-east-1b.eureka="ip-172-31-x-x.ec2.internal" + * where "ip-172-31-x-x.ec2.internal" is the ENI private dns + * + * Service url example: + * eureka.serviceUrl.us-east-1a=http://ip-172-31-x-x.ec2.internal:7001/eureka/v2/ + * + * ENI Binding strategy should be configured via property like: + * + * eureka.awsBindingStrategy=ENI + * + * If there are no available ENI's for the availability zone, it will not attach any already attached ENI + */ +public class ElasticNetworkInterfaceBinderV2 implements AwsBinder { + private static final Logger logger = LoggerFactory.getLogger(ElasticNetworkInterfaceBinder.class); + private static final int IP_BIND_SLEEP_TIME_MS = 1000; + private static final Timer timer = new Timer("Eureka-ElasticNetworkInterfaceBinder", true); + + private final EurekaServerConfig serverConfig; + private final EurekaClientConfig clientConfig; + private final PeerAwareInstanceRegistry registry; + private final ApplicationInfoManager applicationInfoManager; + + @Inject + public ElasticNetworkInterfaceBinderV2( + EurekaServerConfig serverConfig, + EurekaClientConfig clientConfig, + PeerAwareInstanceRegistry registry, + ApplicationInfoManager applicationInfoManager) { + this.serverConfig = serverConfig; + this.clientConfig = clientConfig; + this.registry = registry; + this.applicationInfoManager = applicationInfoManager; + } + + @PostConstruct + public void start() { + int retries = serverConfig.getEIPBindRebindRetries(); + for (int i = 0; i < retries; i++) { + try { + if (alreadyBound()) { + break; + } else { + bind(); + } + } catch (Throwable e) { + logger.error("Cannot bind to IP", e); + try { + Thread.sleep(IP_BIND_SLEEP_TIME_MS); + } catch (InterruptedException e1) { + throw new RuntimeException(e1); + } + } + } + // Schedule a timer which periodically checks for IP binding. + timer.schedule(new IPBindingTask(), serverConfig.getEIPBindingRetryIntervalMsWhenUnbound()); + } + + @PreDestroy + public void shutdown() { + timer.cancel(); + for (int i = 0; i < serverConfig.getEIPBindRebindRetries(); i++) { + try { + unbind(); + break; + } catch (Exception e) { + logger.warn("Cannot unbind the IP from the instance"); + try { + Thread.sleep(IP_BIND_SLEEP_TIME_MS); + } catch (InterruptedException e1) { + throw new RuntimeException(e1); + } + } + } + } + + + public boolean alreadyBound() throws MalformedURLException { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + String myInstanceId = ((AmazonInfo) myInfo.getDataCenterInfo()).get(AmazonInfo.MetaDataKey.instanceId); + Ec2Client ec2Service = getEC2Service(); + List instanceNetworkInterfaces = instanceData(myInstanceId, ec2Service).networkInterfaces(); + List candidateIPs = getCandidateIps(); + for (String ip : candidateIPs) { + for (InstanceNetworkInterface ini : instanceNetworkInterfaces) { + if (ip.equals(ini.privateIpAddress())) { + logger.info("My instance {} seems to be already associated with the ip {}", myInstanceId, ip); + return true; + } + } + } + return false; + } + + /** + * Binds an ENI to the instance. + * + * The candidate ENI's are deduced in the same wa the EIP binder works: Via dns records or via service urls, + * depending on configuration. + * + * It will try to attach the first ENI that is: + * Available + * For this subnet + * In the list of candidate ENI's + * + * @throws MalformedURLException + */ + public void bind() throws MalformedURLException { + InstanceInfo myInfo = ApplicationInfoManager.getInstance().getInfo(); + String myInstanceId = ((AmazonInfo) myInfo.getDataCenterInfo()).get(AmazonInfo.MetaDataKey.instanceId); + String myZone = ((AmazonInfo) myInfo.getDataCenterInfo()).get(AmazonInfo.MetaDataKey.availabilityZone); + + final List ips = getCandidateIps(); + Ordering ipsOrder = Ordering.natural().onResultOf(new Function() { + public Integer apply(NetworkInterface networkInterface) { + return ips.indexOf(networkInterface.privateIpAddress()); + } + }); + + Ec2Client ec2Service = getEC2Service(); + String subnetId = instanceData(myInstanceId, ec2Service).subnetId(); + + DescribeNetworkInterfacesResponse result = ec2Service + .describeNetworkInterfaces(DescribeNetworkInterfacesRequest.builder() + .filters(Filter.builder().name("private-ip-address").values(ips).build(), + Filter.builder().name("status").values("available").build(), + Filter.builder().name("subnet-id").values(subnetId).build()) + .build()); + + if (result.networkInterfaces().isEmpty()) { + logger.info("No ip is free to be associated with this instance. Candidate ips are: {} for zone: {}", ips, myZone); + } else { + NetworkInterface selected = ipsOrder.min(result.networkInterfaces()); + ec2Service.attachNetworkInterface(AttachNetworkInterfaceRequest.builder() + .networkInterfaceId(selected.networkInterfaceId()) + .deviceIndex(1) + .instanceId(myInstanceId) + .build()); + } + } + + /** + * Unbind the IP that this instance is associated with. + */ + public void unbind() throws Exception { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + String myInstanceId = ((AmazonInfo) myInfo.getDataCenterInfo()).get(AmazonInfo.MetaDataKey.instanceId); + + Ec2Client ec2 = getEC2Service(); + + List result = instanceData(myInstanceId, ec2).networkInterfaces(); + + List ips = getCandidateIps(); + + for (InstanceNetworkInterface networkInterface : result) { + if (ips.contains(networkInterface.privateIpAddress())) { + String attachmentId = networkInterface.attachment().attachmentId(); + ec2.detachNetworkInterface(DetachNetworkInterfaceRequest.builder() + .attachmentId(attachmentId) + .build()); + break; + } + } + } + + + private Instance instanceData(String myInstanceId, Ec2Client ec2) { + DescribeInstancesRequest request = DescribeInstancesRequest.builder() + .instanceIds(myInstanceId) + .build(); + DescribeInstancesResponse response = ec2.describeInstances(request); + return response.reservations().get(0).instances().get(0); + } + + /** + * Based on shouldUseDnsForFetchingServiceUrls configuration, either retrieves candidates from dns records or from + * configuration properties. + * + * + */ + public List getCandidateIps() throws MalformedURLException { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + String myZone = ((AmazonInfo) myInfo.getDataCenterInfo()).get(AmazonInfo.MetaDataKey.availabilityZone); + + Collection candidates = clientConfig.shouldUseDnsForFetchingServiceUrls() + ? getIPsForZoneFromDNS(myZone) + : getIPsForZoneFromConfig(myZone); + + if (candidates == null || candidates.size() == 0) { + throw new RuntimeException("Could not get any ips from the pool for zone :" + myZone); + } + List ips = new ArrayList<>(); + + for(String candidate : candidates) { + String host = new URL(candidate).getHost(); + if (InetAddresses.isInetAddress(host)) { + ips.add(host); + } else { + // ip-172-31-55-172.ec2.internal -> ip-172-31-55-172 + String firstPartOfHost = Splitter.on(".").splitToList(host).get(0); + // ip-172-31-55-172 -> [172,31,55,172] + List noIpPrefix = Splitter.on("-").splitToList(firstPartOfHost).subList(1, 5); + // [172,31,55,172] -> 172.31.55.172 + String ip = Joiner.on(".").join(noIpPrefix); + if (InetAddresses.isInetAddress(ip)) { + ips.add(ip); + } else { + throw new IllegalArgumentException("Illegal internal hostname " + host + " translated to '" + ip + "'"); + } + } + } + return ips; + } + + + private Collection getIPsForZoneFromConfig(String myZone) { + return clientConfig.getEurekaServerServiceUrls(myZone); + } + + + private Collection getIPsForZoneFromDNS(String myZone) { + return EndpointUtils.getServiceUrlsFromDNS( + clientConfig, + myZone, + true, + new EndpointUtils.InstanceInfoBasedUrlRandomizer(applicationInfoManager.getInfo()) + ); + } + + private Ec2Client getEC2Service() { + String awsAccessId = serverConfig.getAWSAccessId(); + String awsSecretKey = serverConfig.getAWSSecretKey(); + + String region = clientConfig.getRegion().trim().toLowerCase(); + final Ec2ClientBuilder ec2ServiceBuilder = Ec2Client.builder() + //we set the region based on config and let the SDK determine the endpoint for the service. + .region(Region.of(region)); + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); + ec2ServiceBuilder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); + } + else { + ec2ServiceBuilder.credentialsProvider(InstanceProfileCredentialsProvider.create()); + } + return ec2ServiceBuilder.build(); + } + + private class IPBindingTask extends TimerTask { + @Override + public void run() { + boolean alreadyBound = false; + try { + alreadyBound = alreadyBound(); + // If the ip is not bound, the registry could be stale. First sync up the registry from the + // neighboring node before trying to bind the IP + if (!alreadyBound) { + registry.clearRegistry(); + int count = registry.syncUp(); + registry.openForTraffic(applicationInfoManager, count); + } else { + // An ip is already bound + return; + } + bind(); + } catch (Throwable e) { + logger.error("Could not bind to IP", e); + } finally { + if (alreadyBound) { + timer.schedule(new IPBindingTask(), serverConfig.getEIPBindingRetryIntervalMs()); + } else { + timer.schedule(new IPBindingTask(), serverConfig.getEIPBindingRetryIntervalMsWhenUnbound()); + } + } + } + } +} diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java new file mode 100644 index 0000000000..e70a0bf522 --- /dev/null +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java @@ -0,0 +1,360 @@ +/* + * This class is intended to replace route53Binder in the next major version of eureka-core. + * For now the code is split allowing optional use of aws jdk version 2 + * + * Compare to Route53Binder to understand changes + */ + +package com.netflix.eureka.aws; + +import com.netflix.appinfo.AmazonInfo; +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.appinfo.InstanceInfo; +import com.netflix.discovery.EurekaClientConfig; +import com.netflix.eureka.EurekaServerConfig; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.route53.Route53Client; +import software.amazon.awssdk.services.route53.Route53ClientBuilder; +import software.amazon.awssdk.services.route53.model.*; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +/** + * Route53 binder implementation. Will look for a free domain in the list of service url to bind itself to via Route53. + */ +@Singleton +public class Route53BinderV2 implements AwsBinder { + private static final Logger logger = LoggerFactory + .getLogger(Route53Binder.class); + public static final String NULL_DOMAIN = "null"; + + private final EurekaServerConfig serverConfig; + private final EurekaClientConfig clientConfig; + private final ApplicationInfoManager applicationInfoManager; + + /** + * the hostname to register under the Route53 CNAME + */ + private final String registrationHostname; + + private final Timer timer; + + private final Route53Client amazonRoute53Client; + + + @Inject + public Route53BinderV2(EurekaServerConfig serverConfig, + EurekaClientConfig clientConfig, + ApplicationInfoManager applicationInfoManager) { + this(getRegistrationHostnameFromAmazonDataCenterInfo(applicationInfoManager), + serverConfig, + clientConfig, + applicationInfoManager); + } + + /** + * @param registrationHostname the hostname to register under the Route53 CNAME + */ + public Route53BinderV2(String registrationHostname, EurekaServerConfig serverConfig, + EurekaClientConfig clientConfig, ApplicationInfoManager applicationInfoManager) { + this.registrationHostname = registrationHostname; + this.serverConfig = serverConfig; + this.clientConfig = clientConfig; + this.applicationInfoManager = applicationInfoManager; + this.timer = new Timer("Eureka-Route53Binder", true); + this.amazonRoute53Client = getAmazonRoute53Client(serverConfig); + } + + private static String getRegistrationHostnameFromAmazonDataCenterInfo(ApplicationInfoManager applicationInfoManager) { + InstanceInfo myInfo = applicationInfoManager.getInfo(); + AmazonInfo dataCenterInfo = (AmazonInfo) myInfo.getDataCenterInfo(); + + String ip = dataCenterInfo.get(AmazonInfo.MetaDataKey.publicHostname); + + if (ip == null || ip.length() == 0) { + return dataCenterInfo.get(AmazonInfo.MetaDataKey.localHostname); + } + + return ip; + } + + @Override + @PostConstruct + public void start() { + try { + doBind(); + timer.schedule( + new TimerTask() { + @Override + public void run() { + try { + doBind(); + } catch (Throwable e) { + logger.error("Could not bind to Route53", e); + } + } + }, + serverConfig.getRoute53BindingRetryIntervalMs(), + serverConfig.getRoute53BindingRetryIntervalMs()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void doBind() throws InterruptedException { + List freeDomains = new ArrayList<>(); + List domains = getDeclaredDomains(); + for (String domain : domains) { + ResourceRecordSetWithHostedZone rrs = getResourceRecordSetWithHostedZone(domain); + + if (rrs != null) { + if (rrs.getResourceRecordSet() == null) { + ResourceRecordSet resourceRecordSet = ResourceRecordSet.builder() + .name(domain) + .type(RRType.CNAME) + .ttl(serverConfig.getRoute53DomainTTL()) + .build(); + freeDomains.add(new ResourceRecordSetWithHostedZone(rrs.getHostedZone(), resourceRecordSet)); + } else if (NULL_DOMAIN.equals(rrs.getResourceRecordSet().resourceRecords().get(0).value())) { + freeDomains.add(rrs); + } + // already registered + if (hasValue(rrs, registrationHostname)) { + return; + } + } + } + + for (ResourceRecordSetWithHostedZone rrs : freeDomains) { + if (createResourceRecordSet(rrs)) { + logger.info("Bind {} to {}", registrationHostname, rrs.getResourceRecordSet().name()); + return; + } + } + + logger.warn("Unable to find free domain in {}", domains); + } + + + private boolean createResourceRecordSet(ResourceRecordSetWithHostedZone rrs) throws InterruptedException { + ResourceRecordSet resourceRecordSet = rrs.getResourceRecordSet().toBuilder() + .resourceRecords(ResourceRecord.builder().value(registrationHostname).build()) + .build(); + Change change = Change.builder() + .action(ChangeAction.UPSERT) + .resourceRecordSet(resourceRecordSet) + .build(); + if (executeChangeWithRetry(change, rrs.getHostedZone())) { + Thread.sleep(1000); + // check change not overwritten + ResourceRecordSet updatedResourceRecordSet = getResourceRecordSet(rrs.getResourceRecordSet().name(), rrs.getHostedZone()); + if (updatedResourceRecordSet != null) { + return updatedResourceRecordSet.resourceRecords().equals(resourceRecordSet.resourceRecords()); + } + } + return false; + } + + private List toDomains(List ec2Urls) { + List domains = new ArrayList<>(ec2Urls.size()); + for(String url : ec2Urls) { + try { + domains.add(extractDomain(url)); + } catch(MalformedURLException e) { + logger.error("Invalid url {}", url, e); + } + } + return domains; + } + + private String getMyZone() { + InstanceInfo info = applicationInfoManager.getInfo(); + AmazonInfo amazonInfo = info != null ? (AmazonInfo) info.getDataCenterInfo() : null; + String zone = amazonInfo != null ? amazonInfo.get(AmazonInfo.MetaDataKey.availabilityZone) : null; + if (zone == null) { + throw new RuntimeException("Cannot extract availabilityZone"); + } + return zone; + } + + private List getDeclaredDomains() { + final String myZone = getMyZone(); + List ec2Urls = clientConfig.getEurekaServerServiceUrls(myZone); + return toDomains(ec2Urls); + } + + private boolean executeChangeWithRetry(Change change, HostedZone hostedZone) throws InterruptedException { + Throwable firstError = null; + for (int i = 0; i < serverConfig.getRoute53BindRebindRetries(); i++) { + try { + executeChange(change, hostedZone); + return true; + } catch (Throwable e) { + if (firstError == null) { + firstError = e; + } + Thread.sleep(1000); + } + } + + if (firstError != null) { + logger.error("Cannot execute change {} {}", change, firstError, firstError); + } + + return false; + } + private void executeChange(Change change, HostedZone hostedZone) { + logger.info("Execute change {} ", change); + ChangeBatch changeBatch = ChangeBatch.builder() + .changes(change) + .build(); + ChangeResourceRecordSetsRequest changeResourceRecordSetsRequest = ChangeResourceRecordSetsRequest.builder() + .hostedZoneId(hostedZone.id()) + .changeBatch(changeBatch) + .build(); + + amazonRoute53Client.changeResourceRecordSets(changeResourceRecordSetsRequest); + } + + + private ResourceRecordSetWithHostedZone getResourceRecordSetWithHostedZone(String domain) { + HostedZone hostedZone = getHostedZone(domain); + if (hostedZone != null) { + return new ResourceRecordSetWithHostedZone(hostedZone, getResourceRecordSet(domain, hostedZone)); + } + return null; + } + + private ResourceRecordSet getResourceRecordSet(String domain, HostedZone hostedZone) { + ListResourceRecordSetsRequest request = ListResourceRecordSetsRequest.builder() + .maxItems(String.valueOf(Integer.MAX_VALUE)) + .hostedZoneId(hostedZone.id()) + .build(); + + ListResourceRecordSetsResponse listResourceRecordSetsResult = amazonRoute53Client.listResourceRecordSets(request); + + for (ResourceRecordSet rrs : listResourceRecordSetsResult.resourceRecordSets()) { + if (rrs.name().equals(domain)) { + return rrs; + } + } + + return null; + } + + private HostedZone getHostedZone(String domain) { + ListHostedZonesRequest listHostedZoneRequest = ListHostedZonesRequest.builder() + .maxItems(String.valueOf(Integer.MAX_VALUE)) + .build(); + ListHostedZonesResponse listHostedZonesResult = amazonRoute53Client.listHostedZones(listHostedZoneRequest); + for (HostedZone hostedZone : listHostedZonesResult.hostedZones()) { + if (domain.endsWith(hostedZone.name())) { + return hostedZone; + } + } + return null; + } + + private void unbindFromDomain(String domain) throws InterruptedException { + ResourceRecordSetWithHostedZone resourceRecordSetWithHostedZone = getResourceRecordSetWithHostedZone(domain); + if (hasValue(resourceRecordSetWithHostedZone, registrationHostname)) { + ResourceRecordSet updatedResourceRecordSet = resourceRecordSetWithHostedZone.getResourceRecordSet().toBuilder() + .resourceRecords(ResourceRecord.builder().value(NULL_DOMAIN).build()) + .build(); + Change change = Change.builder() + .action(ChangeAction.UPSERT) + .resourceRecordSet(updatedResourceRecordSet) + .build(); + executeChangeWithRetry(change, resourceRecordSetWithHostedZone.getHostedZone()); + } + } + + + private String extractDomain(String url) throws MalformedURLException { + return new URL(url).getHost() + "."; + } + + @Override + @PreDestroy + public void shutdown() { + timer.cancel(); + + for(String domain : getDeclaredDomains()) { + try { + unbindFromDomain(domain); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + amazonRoute53Client.close(); + } + + private Route53Client getAmazonRoute53Client(EurekaServerConfig serverConfig) { + String awsAccessId = serverConfig.getAWSAccessId(); + String awsSecretKey = serverConfig.getAWSSecretKey(); + + + final Route53ClientBuilder route53ClientBuilder = Route53Client.builder() + .region(Region.of(clientConfig.getRegion().trim().toLowerCase())) + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .apiCallAttemptTimeout(Duration.ofMillis(serverConfig.getASGQueryTimeoutMs())) + .build()); + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); + route53ClientBuilder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); + } else { + route53ClientBuilder.credentialsProvider(InstanceProfileCredentialsProvider.create()); + } + + return route53ClientBuilder.build(); + } + + private boolean hasValue(ResourceRecordSetWithHostedZone resourceRecordSetWithHostedZone, String ip) { + if (resourceRecordSetWithHostedZone != null && resourceRecordSetWithHostedZone.getResourceRecordSet() != null) { + ResourceRecordSet recordSet = resourceRecordSetWithHostedZone.getResourceRecordSet(); + for (ResourceRecord rr : recordSet.resourceRecords()) { + if (ip.equals(rr.value())) { + return true; + } + } + } + return false; + } + + private class ResourceRecordSetWithHostedZone { + private final HostedZone hostedZone; + private final ResourceRecordSet resourceRecordSet; + + public ResourceRecordSetWithHostedZone(HostedZone hostedZone, ResourceRecordSet resourceRecordSet) { + this.hostedZone = hostedZone; + this.resourceRecordSet = resourceRecordSet; + } + + public HostedZone getHostedZone() { + return hostedZone; + } + + public ResourceRecordSet getResourceRecordSet() { + return resourceRecordSet; + } + } + +} diff --git a/eureka-core/src/test/java/com/netflix/eureka/aws/EIPManagerV2Test.java b/eureka-core/src/test/java/com/netflix/eureka/aws/EIPManagerV2Test.java new file mode 100644 index 0000000000..0357cd0764 --- /dev/null +++ b/eureka-core/src/test/java/com/netflix/eureka/aws/EIPManagerV2Test.java @@ -0,0 +1,80 @@ +/* + * Copyright 2018 Netflix, 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.eureka.aws; + +import com.google.common.collect.Lists; +import com.netflix.discovery.EurekaClientConfig; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Joseph Witthuhn + */ +public class EIPManagerV2Test { + private EurekaClientConfig config = mock(EurekaClientConfig.class); + private EIPManagerV2 eipManager; + + @Before + public void setUp() { + when(config.shouldUseDnsForFetchingServiceUrls()).thenReturn(Boolean.FALSE); + eipManager = new EIPManagerV2(null, config, null, null); + } + + @Test + public void shouldFilterNonElasticNames() { + when(config.getRegion()).thenReturn("us-east-1"); + List hosts = Lists.newArrayList("example.com", "ec2-1-2-3-4.compute.amazonaws.com", "5.6.7.8", + "ec2-101-202-33-44.compute.amazonaws.com"); + when(config.getEurekaServerServiceUrls(any(String.class))).thenReturn(hosts); + + Collection returnValue = eipManager.getCandidateEIPs("i-123", "us-east-1d"); + assertEquals(2, returnValue.size()); + assertTrue(returnValue.contains("1.2.3.4")); + assertTrue(returnValue.contains("101.202.33.44")); + } + + @Test + public void shouldFilterNonElasticNamesInOtherRegion() { + when(config.getRegion()).thenReturn("eu-west-1"); + List hosts = Lists.newArrayList("example.com", "ec2-1-2-3-4.eu-west-1.compute.amazonaws.com", + "5.6.7.8", "ec2-101-202-33-44.eu-west-1.compute.amazonaws.com"); + when(config.getEurekaServerServiceUrls(any(String.class))).thenReturn(hosts); + + Collection returnValue = eipManager.getCandidateEIPs("i-123", "eu-west-1a"); + assertEquals(2, returnValue.size()); + assertTrue(returnValue.contains("1.2.3.4")); + assertTrue(returnValue.contains("101.202.33.44")); + } + + @Test(expected = RuntimeException.class) + public void shouldThrowExceptionWhenNoElasticNames() { + when(config.getRegion()).thenReturn("eu-west-1"); + List hosts = Lists.newArrayList("example.com", "5.6.7.8"); + when(config.getEurekaServerServiceUrls(any(String.class))).thenReturn(hosts); + + eipManager.getCandidateEIPs("i-123", "eu-west-1a"); + } +} From cbf0cea77f2d0920876c692c8cbde6a9ae695599 Mon Sep 17 00:00:00 2001 From: Hahn Date: Fri, 30 May 2025 11:01:37 -0500 Subject: [PATCH 2/5] Rename configuration --- .../main/java/com/netflix/eureka/DefaultEurekaServerConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java b/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java index 18d4cd315a..55eec2e399 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java +++ b/eureka-core/src/main/java/com/netflix/eureka/DefaultEurekaServerConfig.java @@ -158,7 +158,7 @@ public String getAWSSecretKey() { @Override public boolean isUseAwsSdkV2(){ - return configInstance.getBooleanProperty(namespace + "useAwsSdkV2Key", false).get(); + return configInstance.getBooleanProperty(namespace + "useAwsSdkV2", false).get(); } From 2199a9a01389b0e95dc1f66cf3f84e58aa83a9f5 Mon Sep 17 00:00:00 2001 From: Hahn Date: Fri, 30 May 2025 11:11:15 -0500 Subject: [PATCH 3/5] Consistent handling of region and allowing for empty region config --- .../com/netflix/eureka/aws/AwsAsgUtilV2.java | 25 +++++++++++++------ .../com/netflix/eureka/aws/EIPManagerV2.java | 11 +++++--- .../aws/ElasticNetworkInterfaceBinderV2.java | 12 ++++++--- .../netflix/eureka/aws/Route53BinderV2.java | 8 +++++- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java index dad8b41ca2..ece23ecbda 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsAsgUtilV2.java @@ -259,10 +259,14 @@ private AutoScalingGroup retrieveAutoScalingGroup(String asgName) { private Credentials initializeStsSession(String asgAccount) { String region = clientConfig.getRegion(); + StsClientBuilder stsBuilder = StsClient.builder() - .credentialsProvider(InstanceProfileCredentialsProvider.create()) - //setting the region based on config, letting jdk resolve endponit - .region(Region.of(region)); + .credentialsProvider(InstanceProfileCredentialsProvider.create()); + + if(region != null && !region.isEmpty()) { + //setting the region based on config, letting jdk resolve endponit + stsBuilder.region(Region.of(region.toLowerCase().trim())); + } StsClient sts = stsBuilder.build(); @@ -298,12 +302,15 @@ private AutoScalingGroup retrieveAutoScalingGroupCrossAccount(String asgAccount, AutoScalingClientBuilder autoScalingClientBuilder = AutoScalingClient.builder() .credentialsProvider(StaticCredentialsProvider.create(awsSessionCredentials)) - //setting region based on config, letting sdk resolve endpoint - .region(Region.of(clientConfig.getRegion())) .overrideConfiguration( ClientOverrideConfiguration.builder() .apiCallAttemptTimeout(Duration.ofMillis(serverConfig.getASGQueryTimeoutMs())) .build()); + String region = clientConfig.getRegion(); + if(region != null && !region.isEmpty()) { + //setting the region based on config, letting jdk resolve endpoint + autoScalingClientBuilder.region(Region.of(region.toLowerCase().trim())); + } AutoScalingClient autoScalingClient = autoScalingClientBuilder.build(); DescribeAutoScalingGroupsRequest request = DescribeAutoScalingGroupsRequest.builder() @@ -479,9 +486,13 @@ private AutoScalingClient getAmazonAutoScalingClient() { .overrideConfiguration( ClientOverrideConfiguration.builder() .apiCallAttemptTimeout(Duration.ofMillis(serverConfig.getASGQueryTimeoutMs())) - .build()) - .region(Region.of(clientConfig.getRegion())); + .build()); + String region = clientConfig.getRegion(); + if(region != null && !region.isEmpty()) { + //setting the region based on config, letting jdk resolve endpoint + builder.region(Region.of(region.toLowerCase().trim())); + } if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); builder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java index c6d8e55c03..6634589fb5 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/EIPManagerV2.java @@ -400,9 +400,14 @@ private Ec2Client getEC2Service() { String awsAccessId = serverConfig.getAWSAccessId(); String awsSecretKey = serverConfig.getAWSSecretKey(); - final Ec2ClientBuilder ec2ServiceBuilder = Ec2Client.builder() - //we set region based on configuration, but let the sdk determine the endpoints - .region(Region.of(clientConfig.getRegion().trim().toLowerCase())); + final Ec2ClientBuilder ec2ServiceBuilder = Ec2Client.builder(); + + String region = clientConfig.getRegion(); + if(region != null && !region.isEmpty()) { + //setting the region based on config, letting jdk resolve endpoint + ec2ServiceBuilder.region(Region.of(region.toLowerCase().trim())); + } + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); ec2ServiceBuilder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java index 0857b0cbf0..889582b367 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/ElasticNetworkInterfaceBinderV2.java @@ -278,10 +278,14 @@ private Ec2Client getEC2Service() { String awsAccessId = serverConfig.getAWSAccessId(); String awsSecretKey = serverConfig.getAWSSecretKey(); - String region = clientConfig.getRegion().trim().toLowerCase(); - final Ec2ClientBuilder ec2ServiceBuilder = Ec2Client.builder() - //we set the region based on config and let the SDK determine the endpoint for the service. - .region(Region.of(region)); + final Ec2ClientBuilder ec2ServiceBuilder = Ec2Client.builder(); + + String region = clientConfig.getRegion(); + if(region != null && !region.isEmpty()) { + //setting the region based on config, letting jdk resolve endpoint + ec2ServiceBuilder.region(Region.of(region.toLowerCase().trim())); + } + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); ec2ServiceBuilder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java index e70a0bf522..916517de9e 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/Route53BinderV2.java @@ -312,11 +312,17 @@ private Route53Client getAmazonRoute53Client(EurekaServerConfig serverConfig) { final Route53ClientBuilder route53ClientBuilder = Route53Client.builder() - .region(Region.of(clientConfig.getRegion().trim().toLowerCase())) .overrideConfiguration( ClientOverrideConfiguration.builder() .apiCallAttemptTimeout(Duration.ofMillis(serverConfig.getASGQueryTimeoutMs())) .build()); + + String region = clientConfig.getRegion(); + if(region != null && !region.isEmpty()) { + //setting the region based on config, letting jdk resolve endpoint + route53ClientBuilder.region(Region.of(region.toLowerCase().trim())); + } + if (awsAccessId != null && !awsAccessId.isEmpty() && awsSecretKey != null && !awsSecretKey.isEmpty()) { AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(awsAccessId, awsSecretKey); route53ClientBuilder.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials)); From aaf255a19d3970889d34dd6b10e0da96d7d86cb9 Mon Sep 17 00:00:00 2001 From: Hahn Date: Mon, 2 Jun 2025 12:20:30 -0500 Subject: [PATCH 4/5] Move sdkV2 switch to EurekaBootStrap --- .../com/netflix/eureka/EurekaBootStrap.java | 7 ++- .../netflix/eureka/aws/AwsBinderDelegate.java | 12 +--- .../eureka/aws/AwsBinderDelegateV2.java | 57 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegateV2.java diff --git a/eureka-core/src/main/java/com/netflix/eureka/EurekaBootStrap.java b/eureka-core/src/main/java/com/netflix/eureka/EurekaBootStrap.java index 8fb3bc1936..13115f16e3 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/EurekaBootStrap.java +++ b/eureka-core/src/main/java/com/netflix/eureka/EurekaBootStrap.java @@ -18,6 +18,7 @@ import com.netflix.discovery.AbstractDiscoveryClientOptionalArgs; import com.netflix.discovery.shared.transport.jersey.TransportClientFactories; +import com.netflix.eureka.aws.AwsBinderDelegateV2; import com.netflix.eureka.transport.EurekaServerHttpClientFactory; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContextEvent; @@ -186,7 +187,11 @@ protected void initEurekaServerContext() throws Exception { eurekaClient, eurekaServerHttpClientFactory ); - awsBinder = new AwsBinderDelegate(eurekaServerConfig, eurekaClient.getEurekaClientConfig(), registry, applicationInfoManager); + if(eurekaServerConfig.isUseAwsSdkV2()) { + awsBinder = new AwsBinderDelegateV2(eurekaServerConfig, eurekaClient.getEurekaClientConfig(), registry, applicationInfoManager); + }else{ + awsBinder = new AwsBinderDelegate(eurekaServerConfig, eurekaClient.getEurekaClientConfig(), registry, applicationInfoManager); + } awsBinder.start(); } else { registry = new PeerAwareInstanceRegistryImpl( diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java index f3bed7a2db..fdf06e23f5 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java @@ -24,19 +24,13 @@ public AwsBinderDelegate(EurekaServerConfig serverConfig, boolean useAwsSdkV2 = serverConfig.isUseAwsSdkV2(); switch (bindingStrategy) { case ROUTE53: - delegate = useAwsSdkV2 ? - new Route53BinderV2(serverConfig, clientConfig, applicationInfoManager) - : new Route53Binder(serverConfig, clientConfig, applicationInfoManager); + delegate =new Route53Binder(serverConfig, clientConfig, applicationInfoManager); break; case EIP: - delegate = useAwsSdkV2 ? - new EIPManagerV2(serverConfig, clientConfig, registry, applicationInfoManager) : - new EIPManager(serverConfig, clientConfig, registry, applicationInfoManager); + delegate = new EIPManager(serverConfig, clientConfig, registry, applicationInfoManager); break; case ENI: - delegate = useAwsSdkV2 ? - new ElasticNetworkInterfaceBinderV2(serverConfig, clientConfig, registry, applicationInfoManager) - : new ElasticNetworkInterfaceBinder(serverConfig, clientConfig, registry, applicationInfoManager); + delegate = new ElasticNetworkInterfaceBinder(serverConfig, clientConfig, registry, applicationInfoManager); break; default: throw new IllegalArgumentException("Unexpected BindingStrategy " + bindingStrategy); diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegateV2.java b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegateV2.java new file mode 100644 index 0000000000..78727a7dd0 --- /dev/null +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegateV2.java @@ -0,0 +1,57 @@ +package com.netflix.eureka.aws; + +import com.netflix.appinfo.ApplicationInfoManager; +import com.netflix.discovery.EurekaClientConfig; +import com.netflix.eureka.EurekaServerConfig; +import com.netflix.eureka.registry.PeerAwareInstanceRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +@Singleton +public class AwsBinderDelegateV2 implements AwsBinder { + + private final AwsBinder delegate; + + @Inject + public AwsBinderDelegateV2(EurekaServerConfig serverConfig, + EurekaClientConfig clientConfig, + PeerAwareInstanceRegistry registry, + ApplicationInfoManager applicationInfoManager) { + AwsBindingStrategy bindingStrategy = serverConfig.getBindingStrategy(); + switch (bindingStrategy) { + case ROUTE53: + delegate = new Route53BinderV2(serverConfig, clientConfig, applicationInfoManager); + break; + case EIP: + delegate = new EIPManagerV2(serverConfig, clientConfig, registry, applicationInfoManager); + break; + case ENI: + delegate = new ElasticNetworkInterfaceBinderV2(serverConfig, clientConfig, registry, applicationInfoManager); + break; + default: + throw new IllegalArgumentException("Unexpected BindingStrategy " + bindingStrategy); + } + } + + @Override + @PostConstruct + public void start() { + try { + delegate.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + @PreDestroy + public void shutdown() { + try { + delegate.shutdown(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file From e42bf44ab9ad2a4534c4615fbb5768881f0c2867 Mon Sep 17 00:00:00 2001 From: Hahn Date: Mon, 2 Jun 2025 12:25:14 -0500 Subject: [PATCH 5/5] Cleanup unused variable --- .../main/java/com/netflix/eureka/aws/AwsBinderDelegate.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java index fdf06e23f5..ae728f1c91 100644 --- a/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java +++ b/eureka-core/src/main/java/com/netflix/eureka/aws/AwsBinderDelegate.java @@ -21,10 +21,9 @@ public AwsBinderDelegate(EurekaServerConfig serverConfig, PeerAwareInstanceRegistry registry, ApplicationInfoManager applicationInfoManager) { AwsBindingStrategy bindingStrategy = serverConfig.getBindingStrategy(); - boolean useAwsSdkV2 = serverConfig.isUseAwsSdkV2(); switch (bindingStrategy) { case ROUTE53: - delegate =new Route53Binder(serverConfig, clientConfig, applicationInfoManager); + delegate = new Route53Binder(serverConfig, clientConfig, applicationInfoManager); break; case EIP: delegate = new EIPManager(serverConfig, clientConfig, registry, applicationInfoManager);