diff --git a/.changes/next-release/feature-AWSSDKforJavav2-65b1d43.json b/.changes/next-release/feature-AWSSDKforJavav2-65b1d43.json new file mode 100644 index 000000000000..60d300f55ff5 --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-65b1d43.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "EC2 Instance Metadata Client is now generally available - https://github.com/aws/aws-sdk-java-v2/issues/61" +} diff --git a/bom/pom.xml b/bom/pom.xml index 3c73b7ee51e8..ca85f6454b35 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1693,6 +1693,11 @@ redshiftserverless ${awsjavasdk.version} + + software.amazon.awssdk + imds + ${awsjavasdk.version} + diff --git a/core/imds/pom.xml b/core/imds/pom.xml new file mode 100644 index 000000000000..354ece021363 --- /dev/null +++ b/core/imds/pom.xml @@ -0,0 +1,138 @@ + + + + + + core + software.amazon.awssdk + 2.17.209-SNAPSHOT + + 4.0.0 + + imds + AWS Java SDK :: Core :: Imds + https://aws.amazon.com/sdkforjava + + + + software.amazon.awssdk + annotations + ${awsjavasdk.version} + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + software.amazon.awssdk + url-connection-client + ${awsjavasdk.version} + test + + + software.amazon.awssdk + http-client-spi + ${awsjavasdk.version} + compile + + + nl.jqno.equalsverifier + equalsverifier + test + + + software.amazon.awssdk + sdk-core + ${awsjavasdk.version} + compile + + + com.github.tomakehurst + wiremock-jre8 + test + + + software.amazon.awssdk + utils + ${awsjavasdk.version} + compile + + + software.amazon.awssdk + json-utils + ${awsjavasdk.version} + compile + + + software.amazon.awssdk + third-party-jackson-core + ${awsjavasdk.version} + compile + + + software.amazon.awssdk + test-utils + ${awsjavasdk.version} + test + + + software.amazon.awssdk + profiles + ${awsjavasdk.version} + compile + + + software.amazon.awssdk + netty-nio-client + ${awsjavasdk.version} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + software.amazon.awssdk.imds + + + + + + + \ No newline at end of file diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataAsyncClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataAsyncClient.java new file mode 100644 index 000000000000..f5359256ff4e --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataAsyncClient.java @@ -0,0 +1,103 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.imds.internal.DefaultEc2MetadataAsyncClient; +import software.amazon.awssdk.utils.SdkAutoCloseable; + +/** + * Interface to represent the Ec2Metadata Client Class. Used to access instance metadata from a running instance. + */ +@SdkPublicApi +public interface Ec2MetadataAsyncClient extends SdkAutoCloseable { + + /** + * Gets the specified instance metadata value by the given path. For more information about instance metadata, check the + * Instance metadata documentation. + * + * @param path Input path + * @return A CompletableFuture that completes when the MetadataResponse is made available. + */ + CompletableFuture get(String path); + + /** + * Create an {@link Ec2MetadataAsyncClient} instance using the default values. + * + * @return the client instance. + */ + static Ec2MetadataAsyncClient create() { + return builder().build(); + } + + /** + * Creates a builder for an async client instance. + * @return the newly created builder instance. + */ + static Ec2MetadataAsyncClient.Builder builder() { + return DefaultEc2MetadataAsyncClient.builder(); + } + + /** + * The builder definition for a {@link Ec2MetadataClient}. All parameters are optional and have default values if not + * specified. Therefore, an instance can be simply created with {@code Ec2MetadataAsyncClient.builder().build()} or + * {@code Ec2MetadataAsyncClient.create()}, both having the same result. + */ + interface Builder extends Ec2MetadataClientBuilder { + + /** + * Define the {@link ScheduledExecutorService} used to schedule asynchronous retry attempts. If provided, the + * Ec2MetadataClient will NOT manage the lifetime if the httpClient and must therefore be + * closed explicitly by calling the {@link SdkAsyncHttpClient#close()} method on it. + *

+ * If not specified, defaults to {@link Executors#newScheduledThreadPool} with a default value of 3 thread in the + * pool. + * + * @param scheduledExecutorService the ScheduledExecutorService to use for retry attempt. + * @return a reference to this builder + */ + Builder scheduledExecutorService(ScheduledExecutorService scheduledExecutorService); + + /** + * Define the http client used by the Ec2 Metadata client. If provided, the Ec2MetadataClient will NOT manage the + * lifetime if the httpClient and must therefore be closed explicitly by calling the {@link SdkAsyncHttpClient#close()} + * method on it. + *

+ * If not specified, the IMDS client will look for a SdkAsyncHttpClient class included in the classpath of the + * application and creates a new instance of that class, managed by the IMDS Client, that will be closed when the IMDS + * Client is closed. If no such class can be found, will throw a {@link SdkClientException}. + * + * @param httpClient the http client + * @return a reference to this builder + */ + Builder httpClient(SdkAsyncHttpClient httpClient); + + /** + * An http client builder used to retrieve an instance of an {@link SdkAsyncHttpClient}. If specified, the Ec2 + * Metadata Client will use the instance returned by the builder and manage its lifetime by closing the http client + * once the Ec2 Client itself is closed. + * + * @param builder the builder to used to retrieve an instance. + * @return a reference to this builder + */ + Builder httpClient(SdkAsyncHttpClient.Builder builder); + } +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataClient.java new file mode 100644 index 000000000000..57a40d602e39 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataClient.java @@ -0,0 +1,87 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.imds.internal.DefaultEc2MetadataClient; +import software.amazon.awssdk.utils.SdkAutoCloseable; + + +/** + * Interface to represent the Ec2Metadata Client Class. Used to access instance metadata from a running instance. + */ +@SdkPublicApi +public interface Ec2MetadataClient extends SdkAutoCloseable { + + /** + * Gets the specified instance metadata value by the given path. For more information about instance metadata, check the + * Instance metadata documentation + * + * @param path Input path + * @return Instance metadata value as part of MetadataResponse Object + */ + Ec2MetadataResponse get(String path); + + /** + * Create an {@link Ec2MetadataClient} instance using the default values. + * + * @return the client instance. + */ + static Ec2MetadataClient create() { + return builder().build(); + } + + /** + * Creates a default builder for {@link Ec2MetadataClient}. + */ + static Builder builder() { + return DefaultEc2MetadataClient.builder(); + } + + /** + * The builder definition for a {@link Ec2MetadataClient}. + */ + interface Builder extends Ec2MetadataClientBuilder { + + /** + * Define the http client used by the Ec2 Metadata client. If provided, the Ec2MetadataClient will NOT manage the + * lifetime if the httpClient and must therefore be closed explicitly by calling the {@link SdkAsyncHttpClient#close()} + * method on it. + *

+ * If not specified, the IMDS client will look for a SdkHttpClient class included in the classpath of the + * application and creates a new instance of that class, managed by the IMDS Client, that will be closed when the IMDS + * Client is closed. If no such class can be found, will throw a {@link SdkClientException}. + * + * @param httpClient the http client + * @return a reference to this builder + */ + Builder httpClient(SdkHttpClient httpClient); + + /** + * A http client builder used to retrieve an instance of an {@link SdkHttpClient}. If specified, the Ec2 Metadata Client + * will use the instance returned by the builder and manage its lifetime by closing the http client once the Ec2 Client + * itself is closed. + * + * @param builder the builder to used to retrieve an instance. + * @return a reference to this builder + */ + Builder httpClient(SdkHttpClient.Builder builder); + } + +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataClientBuilder.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataClientBuilder.java new file mode 100644 index 000000000000..5cb056f12c51 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataClientBuilder.java @@ -0,0 +1,95 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import java.net.URI; +import java.time.Duration; +import java.util.function.Consumer; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.imds.internal.Ec2MetadataEndpointProvider; +import software.amazon.awssdk.utils.builder.SdkBuilder; + +/** + * Base shared builder interface for Ec2MetadataClients, sync and async. + * @param the Builder Type + * @param the Ec2MetadataClient Type + */ +@SdkPublicApi +public interface Ec2MetadataClientBuilder extends SdkBuilder, T> { + /** + * Define the retry policy which includes the number of retry attempts for any failed request. + *

+ * If not specified, defaults to 3 retry attempts and a {@link BackoffStrategy#defaultStrategy()} backoff strategy} that + * uses {@link RetryMode#STANDARD}. Can be also specified by using the + * {@link Ec2MetadataClientBuilder#retryPolicy(Consumer)} method. if any of the retryPolicy methods are called multiple times, + * only the last invocation will be considered. + * + * @param retryPolicy The retry policy which includes the number of retry attempts for any failed request. + * @return a reference to this builder + */ + B retryPolicy(Ec2MetadataRetryPolicy retryPolicy); + + /** + * Define the retry policy which includes the number of retry attempts for any failed request. Can be used instead of + * {@link Ec2MetadataClientBuilder#retryPolicy(Ec2MetadataRetryPolicy)} to use a "fluent consumer" syntax. User + * should not manually build the builder in the consumer. + *

+ * If not specified, defaults to 3 retry attempts and a {@link BackoffStrategy#defaultStrategy()} backoff strategy} that + * uses {@link RetryMode#STANDARD}. Can be also specified by using the + * {@link Ec2MetadataClientBuilder#retryPolicy(Ec2MetadataRetryPolicy)} method. if any of the retryPolicy methods are + * called multiple times, only the last invocation will be considered. + * + * @param builderConsumer the consumer + * @return a reference to this builder + */ + B retryPolicy(Consumer builderConsumer); + + /** + * Define the endpoint of IMDS. + *

+ * If not specified, the IMDS client will attempt to automatically resolve the endpoint value + * based on the logic of {@link Ec2MetadataEndpointProvider}. + * + * @param endpoint The endpoint of IMDS. + * @return a reference to this builder + */ + B endpoint(URI endpoint); + + /** + * Define the Time to live (TTL) of the token. The token represents a logical session. The TTL specifies the length of time + * that the token is valid and, therefore, the duration of the session. Defaults to 21,600 seconds (6 hours) if not specified. + * + * @param tokenTtl The Time to live (TTL) of the token. + * @return a reference to this builder + */ + B tokenTtl(Duration tokenTtl); + + /** + * Define the endpoint mode of IMDS. Supported values include IPv4 and IPv6. Used to determine the endpoint of the IMDS + * Client only if {@link Ec2MetadataClientBuilder#endpoint(URI)} is not specified. Only one of both endpoint or endpoint mode + * but not both. If both are specified in the Builder, a {@link IllegalArgumentException} will be thrown. + *

+ * If not specified, the IMDS client will attempt to automatically resolve the endpoint mode value + * based on the logic of {@link Ec2MetadataEndpointProvider}. + * + * @param endpointMode The endpoint mode of IMDS. Supported values include IPv4 and IPv6. + * @return a reference to this builder + */ + B endpointMode(EndpointMode endpointMode); + +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataResponse.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataResponse.java new file mode 100644 index 000000000000..6cb27461aaed --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataResponse.java @@ -0,0 +1,104 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.List; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.document.Document; +import software.amazon.awssdk.imds.internal.unmarshall.document.DocumentUnmarshaller; +import software.amazon.awssdk.protocols.jsoncore.JsonNode; +import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; +import software.amazon.awssdk.thirdparty.jackson.core.JsonParseException; +import software.amazon.awssdk.utils.ToString; +import software.amazon.awssdk.utils.Validate; + +/** + * This class is used for response handling and parsing the metadata fetched by the get call in the {@link Ec2MetadataClient} + * interface. It provides convenience methods to the users to parse the metadata as a String and List. Also provides + * ways to parse the metadata as Document type if it is in the json format. + */ +@SdkPublicApi +public final class Ec2MetadataResponse { + + private static final JsonNodeParser JSON_NODE_PARSER = JsonNode.parserBuilder().removeErrorLocations(true).build(); + + private final String body; + + private Ec2MetadataResponse(String body) { + this.body = Validate.notNull(body, "Metadata is null"); + } + + /** + * Create a {@link Ec2MetadataResponse} with the given body as it's content. + * @param body the content of the response + * @return a {@link Ec2MetadataResponse} with the given body as it's content. + */ + public static Ec2MetadataResponse create(String body) { + return new Ec2MetadataResponse(body); + } + + /** + * @return String Representation of the Metadata Response Body. + */ + public String asString() { + return body; + } + + /** + * Splits the Metadata response body on new line character and returns it as a list. + * @return The Metadata response split on each line. + */ + public List asList() { + return Arrays.asList(body.split("\n")); + } + + /** + * Parses the response String into a {@link Document} type. This method can be used for + * parsing the metadata in a String Json Format. + * @return Document Representation, as json, of the Metadata Response Body. + * @throws UncheckedIOException (wrapping a {@link JsonParseException} if the Response body is not of JSON format. + */ + public Document asDocument() { + JsonNode node = JSON_NODE_PARSER.parse(body); + return node.visit(new DocumentUnmarshaller()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Ec2MetadataResponse that = (Ec2MetadataResponse) o; + return body.equals(that.body); + } + + @Override + public int hashCode() { + return body.hashCode(); + } + + @Override + public String toString() { + return ToString.builder("MetadataResponse") + .add("body", body) + .build(); + } +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicy.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicy.java new file mode 100644 index 000000000000..6b6364d4636d --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicy.java @@ -0,0 +1,152 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.utils.Validate; +import software.amazon.awssdk.utils.builder.CopyableBuilder; +import software.amazon.awssdk.utils.builder.ToCopyableBuilder; + +/** + * Interface for specifying a retry policy to use when evaluating whether or not a request should be retried , and the gap + * between each retry. The {@link #builder()} can be used to construct a retry policy with numRetries and backoffStrategy. + *

+ * When using the {@link #builder()} the SDK will use default values for fields that are not provided.A custom BackoffStrategy + * can be used to construct a policy or a default {@link BackoffStrategy} is used. + * + * @see BackoffStrategy for a list of SDK provided backoff strategies + */ +@SdkPublicApi +public final class Ec2MetadataRetryPolicy implements ToCopyableBuilder { + + private static final int DEFAULT_RETRY_ATTEMPTS = 3; + + private final BackoffStrategy backoffStrategy; + private final Integer numRetries; + + private Ec2MetadataRetryPolicy(BuilderImpl builder) { + + this.numRetries = Validate.getOrDefault(builder.numRetries, () -> DEFAULT_RETRY_ATTEMPTS); + + this.backoffStrategy = Validate.getOrDefault(builder.backoffStrategy, + () -> BackoffStrategy.defaultStrategy(RetryMode.STANDARD)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Ec2MetadataRetryPolicy ec2MetadataRetryPolicy = (Ec2MetadataRetryPolicy) obj; + + if (!Objects.equals(numRetries, ec2MetadataRetryPolicy.numRetries)) { + return false; + } + return Objects.equals(backoffStrategy, ec2MetadataRetryPolicy.backoffStrategy); + } + + @Override + public int hashCode() { + + int result = Math.max(numRetries, 0); + result = 31 * result + (backoffStrategy != null ? backoffStrategy.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "Ec2MetadataRetryPolicy{" + + "backoffStrategy=" + backoffStrategy.toString() + + ", numRetries=" + numRetries + + '}'; + } + + /** + * Method to return the number of retries allowed. + * @return The number of retries allowed. + */ + public int numRetries() { + return numRetries; + } + + /** + * Method to return the BackoffStrategy used. + * @return The backoff Strategy used. + */ + public BackoffStrategy backoffStrategy() { + return backoffStrategy; + } + + public static Builder builder() { + return new BuilderImpl(); + } + + @Override + public Builder toBuilder() { + return builder().numRetries(numRetries) + .backoffStrategy(backoffStrategy); + } + + public static Ec2MetadataRetryPolicy none() { + return builder().numRetries(0).build(); + } + + public interface Builder extends CopyableBuilder { + + /** + * Configure the backoff strategy that should be used for waiting in between retry attempts. + */ + Builder backoffStrategy(BackoffStrategy backoffStrategy); + + /** + * Configure the maximum number of times that a single request should be retried, assuming it fails for a retryable error. + */ + Builder numRetries(Integer numRetries); + + } + + private static final class BuilderImpl implements Builder { + + private Integer numRetries; + private BackoffStrategy backoffStrategy; + + private BuilderImpl() { + } + + @Override + public Builder numRetries(Integer numRetries) { + this.numRetries = numRetries; + return this; + } + + @Override + public Builder backoffStrategy(BackoffStrategy backoffStrategy) { + this.backoffStrategy = backoffStrategy; + return this; + } + + @Override + public Ec2MetadataRetryPolicy build() { + return new Ec2MetadataRetryPolicy(this); + } + } +} \ No newline at end of file diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/EndpointMode.java b/core/imds/src/main/java/software/amazon/awssdk/imds/EndpointMode.java new file mode 100644 index 000000000000..0c926eecab8c --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/EndpointMode.java @@ -0,0 +1,48 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * Enum Class for the Endpoint Mode. + */ +@SdkPublicApi +public enum EndpointMode { + + IPV4, + IPV6; + + /** + * Returns the appropriate EndpointMode Value after parsing the parameter. + * @param s EndpointMode in String Format. + * @return EndpointMode enumValue (IPV4 or IPV6). + * @throws IllegalArgumentException Unrecognized value for endpoint mode. + */ + public static EndpointMode fromValue(String s) { + if (s == null) { + return null; + } + + for (EndpointMode value : values()) { + if (value.name().equalsIgnoreCase(s)) { + return value; + } + } + + throw new IllegalArgumentException("Unrecognized value for endpoint mode: " + s); + } +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/AsyncHttpRequestHelper.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/AsyncHttpRequestHelper.java new file mode 100644 index 000000000000..73a70ab4ea02 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/AsyncHttpRequestHelper.java @@ -0,0 +1,114 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static software.amazon.awssdk.imds.internal.BaseEc2MetadataClient.uncheckedInputStreamToUtf8; +import static software.amazon.awssdk.imds.internal.RequestMarshaller.EC2_METADATA_TOKEN_TTL_HEADER; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.exception.RetryableException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.http.HttpResponseHandler; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.internal.http.TransformingAsyncResponseHandler; +import software.amazon.awssdk.core.internal.http.async.AsyncResponseHandler; +import software.amazon.awssdk.core.internal.http.async.SimpleHttpContentPublisher; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.HttpStatusFamily; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpFullResponse; +import software.amazon.awssdk.http.async.AsyncExecuteRequest; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.http.async.SdkHttpContentPublisher; +import software.amazon.awssdk.utils.CompletableFutureUtils; + +@SdkInternalApi +final class AsyncHttpRequestHelper { + + private AsyncHttpRequestHelper() { + // static utility class + } + + public static CompletableFuture sendAsyncMetadataRequest(SdkAsyncHttpClient httpClient, + SdkHttpFullRequest baseRequest, + CompletableFuture parentFuture) { + return sendAsync(httpClient, baseRequest, AsyncHttpRequestHelper::handleResponse, parentFuture); + } + + public static CompletableFuture sendAsyncTokenRequest(SdkAsyncHttpClient httpClient, + SdkHttpFullRequest baseRequest) { + return sendAsync(httpClient, baseRequest, AsyncHttpRequestHelper::handleTokenResponse, null); + } + + private static CompletableFuture sendAsync(SdkAsyncHttpClient client, + SdkHttpFullRequest request, + HttpResponseHandler handler, + CompletableFuture parentFuture) { + SdkHttpContentPublisher requestContentPublisher = new SimpleHttpContentPublisher(request); + TransformingAsyncResponseHandler responseHandler = + new AsyncResponseHandler<>(handler, Function.identity(), new ExecutionAttributes()); + CompletableFuture responseHandlerFuture = responseHandler.prepare(); + AsyncExecuteRequest metadataRequest = AsyncExecuteRequest.builder() + .request(request) + .requestContentPublisher(requestContentPublisher) + .responseHandler(responseHandler) + .build(); + CompletableFuture executeFuture = client.execute(metadataRequest); + if (parentFuture != null) { + CompletableFutureUtils.forwardExceptionTo(parentFuture, executeFuture); + CompletableFutureUtils.forwardExceptionTo(parentFuture, responseHandlerFuture); + } + return responseHandlerFuture; + } + + private static String handleResponse(SdkHttpFullResponse response, ExecutionAttributes executionAttributes) { + HttpStatusFamily statusCode = HttpStatusFamily.of(response.statusCode()); + AbortableInputStream inputStream = + response.content().orElseThrow(() -> SdkClientException.create("Unexpected error: empty response content")); + String responseContent = uncheckedInputStreamToUtf8(inputStream); + + // non-retryable error + if (statusCode.isOneOf(HttpStatusFamily.CLIENT_ERROR)) { + throw SdkClientException.builder().message(responseContent).build(); + } + + // retryable error + if (statusCode.isOneOf(HttpStatusFamily.SERVER_ERROR)) { + throw RetryableException.create(responseContent); + } + return responseContent; + } + + private static Token handleTokenResponse(SdkHttpFullResponse response, ExecutionAttributes executionAttributes) { + String tokenValue = handleResponse(response, executionAttributes); + Optional ttl = response.firstMatchingHeader(EC2_METADATA_TOKEN_TTL_HEADER); + + if (!ttl.isPresent()) { + throw SdkClientException.create(EC2_METADATA_TOKEN_TTL_HEADER + " header not found in token response"); + } + try { + Duration ttlDuration = Duration.ofSeconds(Long.parseLong(ttl.get())); + return new Token(tokenValue, ttlDuration); + } catch (NumberFormatException nfe) { + throw SdkClientException.create( + "Invalid token format received from IMDS server. Token received: " + tokenValue, nfe); + } + } +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/AsyncTokenCache.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/AsyncTokenCache.java new file mode 100644 index 000000000000..85c98e0f786e --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/AsyncTokenCache.java @@ -0,0 +1,131 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.utils.Logger; + +/** + * A cache for the IMDS {@link Token}, which can be refreshed through an asynchronous operation. + * A call to the {@link AsyncTokenCache#get()} method returns an already completed future if the cached token is still fresh. + * If the cached token was expired, the returned future will be completed once the refresh process has been + * completed. + * In the case where multiple call to

get
are made while the token is expired, all CompletableFuture returned + * will be completed once the single refresh process completes. + * + */ +@SdkInternalApi +final class AsyncTokenCache implements Supplier> { + + private static final Logger log = Logger.loggerFor(AsyncTokenCache.class); + + /** + * The currently cached value. + * Only modified through synchronized block, under the refreshLock. + */ + private volatile Token cachedToken; + + /** + * The asynchronous operation that is used to refresh the token. + * The Supplier must not block the current thread and is responsible to start the process that will complete the future. + * A call the {@link AsyncTokenCache#get} method does not join or wait for the supplied future to finish, it only refreshes + * the token once it finishes. + */ + private final Supplier> supplier; + + /** + * The collection of future that are waiting for the refresh call to complete. If a call to {@link AsyncTokenCache#get()} + * is made while the token request is happening, a future will be added to this collection. All future in this collection + * are completed once the token request is done. + * Should only be modified while holding the lock on {@link AsyncTokenCache#refreshLock}. + */ + private Collection> waitingFutures = new ArrayList<>(); + + /** + * Indicates if the token refresh request is currently running or not. + */ + private final AtomicBoolean refreshRunning = new AtomicBoolean(false); + + private final Object refreshLock = new Object(); + + AsyncTokenCache(Supplier> supplier) { + this.supplier = supplier; + } + + @Override + public CompletableFuture get() { + Token currentValue = cachedToken; + if (!needsRefresh(currentValue)) { + log.debug(() -> "IMDS Token is not expired"); + return CompletableFuture.completedFuture(currentValue); + } + synchronized (refreshLock) { + // Make sure the value wasn't refreshed while we were waiting for the lock. + currentValue = cachedToken; + if (!needsRefresh(currentValue)) { + return CompletableFuture.completedFuture(currentValue); + } + CompletableFuture result = new CompletableFuture<>(); + waitingFutures.add(result); + if (!refreshRunning.get()) { + startRefresh(); + } + return result; + } + } + + private void startRefresh() { + log.debug(() -> "IMDS token expired or null, starting asynchronous refresh."); + CompletableFuture tokenRequest = supplier.get(); + refreshRunning.set(true); // After supplier.get(), in case that throws an exception + tokenRequest.whenComplete((token, throwable) -> { + Collection> toComplete; + synchronized (refreshLock) { + // Instead of completing the waiting future while holding the lock, we copy the list reference and + // release the lock before completing them. This is just in case someone (naughty) is doing something + // blocking on the complete calls. It's not good that they're doing that, but at least + // it won't block other threads trying to acquire the lock. + toComplete = waitingFutures; + waitingFutures = new ArrayList<>(); + refreshRunning.set(false); + if (token != null) { + log.debug(() -> "IMDS token refresh completed. Token value: " + token.value()); + cachedToken = token; + } else { + log.error(() -> "IMDS token refresh completed with error.", throwable); + } + } + + toComplete.forEach(future -> { + if (throwable == null) { + future.complete(token); + } else { + future.completeExceptionally(throwable); + } + }); + }); + } + + private boolean needsRefresh(Token token) { + return token == null || token.isExpired(); + } + +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java new file mode 100644 index 000000000000..915753c00a33 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java @@ -0,0 +1,99 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.exception.RetryableException; +import software.amazon.awssdk.core.retry.RetryPolicyContext; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy; +import software.amazon.awssdk.imds.EndpointMode; +import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.IoUtils; +import software.amazon.awssdk.utils.Logger; +import software.amazon.awssdk.utils.Validate; + +@SdkInternalApi +public abstract class BaseEc2MetadataClient { + + protected static final Duration DEFAULT_TOKEN_TTL = Duration.of(21_600, ChronoUnit.SECONDS); + protected static final AttributeMap IMDS_HTTP_DEFAULTS = + AttributeMap.builder() + .put(SdkHttpConfigurationOption.CONNECTION_TIMEOUT, Duration.ofSeconds(1)) + .put(SdkHttpConfigurationOption.READ_TIMEOUT, Duration.ofSeconds(1)) + .build(); + + private static final Logger log = Logger.loggerFor(BaseEc2MetadataClient.class); + + protected final Ec2MetadataRetryPolicy retryPolicy; + protected final URI endpoint; + protected final RequestMarshaller requestMarshaller; + protected final Duration tokenTtl; + + private BaseEc2MetadataClient(Ec2MetadataRetryPolicy retryPolicy, Duration tokenTtl, URI endpoint, + EndpointMode endpointMode) { + this.retryPolicy = Validate.getOrDefault(retryPolicy, Ec2MetadataRetryPolicy.builder()::build); + this.tokenTtl = Validate.getOrDefault(tokenTtl, () -> DEFAULT_TOKEN_TTL); + this.endpoint = getEndpoint(endpoint, endpointMode); + this.requestMarshaller = new RequestMarshaller(this.endpoint); + } + + protected BaseEc2MetadataClient(DefaultEc2MetadataClient.Ec2MetadataBuilder builder) { + this(builder.getRetryPolicy(), builder.getTokenTtl(), builder.getEndpoint(), builder.getEndpointMode()); + } + + protected BaseEc2MetadataClient(DefaultEc2MetadataAsyncClient.Ec2MetadataAsyncBuilder builder) { + this(builder.getRetryPolicy(), builder.getTokenTtl(), builder.getEndpoint(), builder.getEndpointMode()); + } + + private URI getEndpoint(URI builderEndpoint, EndpointMode builderEndpointMode) { + Validate.mutuallyExclusive("Only one of 'endpoint' or 'endpointMode' must be specified, but not both", + builderEndpoint, builderEndpointMode); + if (builderEndpoint != null) { + return builderEndpoint; + } + if (builderEndpointMode != null) { + return URI.create(Ec2MetadataEndpointProvider.instance().resolveEndpoint(builderEndpointMode)); + } + EndpointMode resolvedEndpointMode = Ec2MetadataEndpointProvider.instance().resolveEndpointMode(); + return URI.create(Ec2MetadataEndpointProvider.instance().resolveEndpoint(resolvedEndpointMode)); + } + + protected static String uncheckedInputStreamToUtf8(AbortableInputStream inputStream) { + try { + return IoUtils.toUtf8String(inputStream); + } catch (IOException ioe) { + throw new UncheckedIOException(ioe); + } finally { + IoUtils.closeQuietly(inputStream, log.logger()); + } + } + + protected boolean shouldRetry(RetryPolicyContext retryPolicyContext, Throwable error) { + boolean maxAttemptReached = retryPolicyContext.retriesAttempted() >= retryPolicy.numRetries(); + if (maxAttemptReached) { + return false; + } + return error instanceof RetryableException || error.getCause() instanceof RetryableException; + } + +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataAsyncClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataAsyncClient.java new file mode 100644 index 000000000000..cb8ca87a32bb --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataAsyncClient.java @@ -0,0 +1,239 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static software.amazon.awssdk.imds.internal.AsyncHttpRequestHelper.sendAsyncTokenRequest; + +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import software.amazon.awssdk.annotations.Immutable; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.internal.http.loader.DefaultSdkAsyncHttpClientBuilder; +import software.amazon.awssdk.core.retry.RetryPolicyContext; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.imds.Ec2MetadataAsyncClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; +import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy; +import software.amazon.awssdk.imds.EndpointMode; +import software.amazon.awssdk.utils.CompletableFutureUtils; +import software.amazon.awssdk.utils.Either; +import software.amazon.awssdk.utils.Logger; +import software.amazon.awssdk.utils.ThreadFactoryBuilder; +import software.amazon.awssdk.utils.Validate; + +@SdkInternalApi +@Immutable +@ThreadSafe +public final class DefaultEc2MetadataAsyncClient extends BaseEc2MetadataClient implements Ec2MetadataAsyncClient { + + private static final Logger log = Logger.loggerFor(DefaultEc2MetadataClient.class); + private static final int DEFAULT_RETRY_THREAD_POOL_SIZE = 3; + + private final SdkAsyncHttpClient httpClient; + private final ScheduledExecutorService asyncRetryScheduler; + private final boolean httpClientIsInternal; + private final boolean retryExecutorIsInternal; + private final AsyncTokenCache tokenCache; + + private DefaultEc2MetadataAsyncClient(Ec2MetadataAsyncBuilder builder) { + super(builder); + + Validate.isTrue(builder.httpClient == null || builder.httpClientBuilder == null, + "The httpClient and the httpClientBuilder can't both be configured."); + this.httpClient = Either + .fromNullable(builder.httpClient, builder.httpClientBuilder) + .map(e -> e.map(Function.identity(), SdkAsyncHttpClient.Builder::build)) + .orElseGet(() -> new DefaultSdkAsyncHttpClientBuilder().buildWithDefaults(IMDS_HTTP_DEFAULTS)); + this.httpClientIsInternal = builder.httpClient == null; + + this.asyncRetryScheduler = Validate.getOrDefault( + builder.scheduledExecutorService, + () -> { + ThreadFactory threadFactory = + new ThreadFactoryBuilder().threadNamePrefix("IMDS-ScheduledExecutor").build(); + return Executors.newScheduledThreadPool(DEFAULT_RETRY_THREAD_POOL_SIZE, threadFactory); + }); + this.retryExecutorIsInternal = builder.scheduledExecutorService == null; + Supplier> tokenSupplier = () -> { + SdkHttpFullRequest baseTokenRequest = requestMarshaller.createTokenRequest(tokenTtl); + return sendAsyncTokenRequest(httpClient, baseTokenRequest); + }; + + this.tokenCache = new AsyncTokenCache(tokenSupplier); + } + + public static Ec2MetadataAsyncClient.Builder builder() { + return new DefaultEc2MetadataAsyncClient.Ec2MetadataAsyncBuilder(); + } + + @Override + public CompletableFuture get(String path) { + CompletableFuture returnFuture = new CompletableFuture<>(); + get(path, RetryPolicyContext.builder().retriesAttempted(0).build(), returnFuture); + return returnFuture; + } + + private void get(String path, RetryPolicyContext retryPolicyContext, CompletableFuture returnFuture) { + CompletableFuture tokenFuture = tokenCache.get(); + + CompletableFuture result = tokenFuture.thenCompose(token -> { + SdkHttpFullRequest baseMetadataRequest = requestMarshaller.createDataRequest(path, token.value(), tokenTtl); + return AsyncHttpRequestHelper.sendAsyncMetadataRequest(httpClient, baseMetadataRequest, returnFuture); + }).thenApply(Ec2MetadataResponse::create); + + CompletableFutureUtils.forwardExceptionTo(returnFuture, result); + + result.whenComplete((response, error) -> { + if (response != null) { + returnFuture.complete(response); + return; + } + if (!shouldRetry(retryPolicyContext, error)) { + returnFuture.completeExceptionally(error); + return; + } + int newAttempt = retryPolicyContext.retriesAttempted() + 1; + log.debug(() -> "Retrying request: Attempt " + newAttempt); + RetryPolicyContext newContext = + RetryPolicyContext.builder() + .retriesAttempted(newAttempt) + .exception(SdkClientException.create(error.getMessage(), error)) + .build(); + scheduledRetryAttempt(() -> get(path, newContext, returnFuture), newContext); + }); + } + + private void scheduledRetryAttempt(Runnable runnable, RetryPolicyContext retryPolicyContext) { + Duration retryDelay = retryPolicy.backoffStrategy().computeDelayBeforeNextRetry(retryPolicyContext); + Executor retryExecutor = retryAttempt -> + asyncRetryScheduler.schedule(retryAttempt, retryDelay.toMillis(), TimeUnit.MILLISECONDS); + CompletableFuture.runAsync(runnable, retryExecutor); + } + + @Override + public void close() { + if (httpClientIsInternal) { + httpClient.close(); + } + if (retryExecutorIsInternal) { + asyncRetryScheduler.shutdown(); + } + } + + protected static final class Ec2MetadataAsyncBuilder implements Ec2MetadataAsyncClient.Builder { + + private Ec2MetadataRetryPolicy retryPolicy; + + private URI endpoint; + + private Duration tokenTtl; + + private EndpointMode endpointMode; + + private SdkAsyncHttpClient httpClient; + + private SdkAsyncHttpClient.Builder httpClientBuilder; + + private ScheduledExecutorService scheduledExecutorService; + + private Ec2MetadataAsyncBuilder() { + } + + @Override + public Ec2MetadataAsyncBuilder retryPolicy(Ec2MetadataRetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + return this; + } + + @Override + public Ec2MetadataAsyncBuilder retryPolicy(Consumer builderConsumer) { + Validate.notNull(builderConsumer, "builderConsumer must not be null"); + Ec2MetadataRetryPolicy.Builder builder = Ec2MetadataRetryPolicy.builder(); + builderConsumer.accept(builder); + this.retryPolicy = builder.build(); + return this; + } + + @Override + public Ec2MetadataAsyncBuilder endpoint(URI endpoint) { + this.endpoint = endpoint; + return this; + } + + @Override + public Ec2MetadataAsyncBuilder tokenTtl(Duration tokenTtl) { + this.tokenTtl = tokenTtl; + return this; + } + + @Override + public Ec2MetadataAsyncBuilder endpointMode(EndpointMode endpointMode) { + this.endpointMode = endpointMode; + return this; + } + + @Override + public Ec2MetadataAsyncBuilder httpClient(SdkAsyncHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + @Override + public Builder httpClient(SdkAsyncHttpClient.Builder builder) { + this.httpClientBuilder = builder; + return this; + } + + @Override + public Ec2MetadataAsyncBuilder scheduledExecutorService(ScheduledExecutorService scheduledExecutorService) { + this.scheduledExecutorService = scheduledExecutorService; + return this; + } + + public Ec2MetadataRetryPolicy getRetryPolicy() { + return this.retryPolicy; + } + + public URI getEndpoint() { + return this.endpoint; + } + + public Duration getTokenTtl() { + return this.tokenTtl; + } + + public EndpointMode getEndpointMode() { + return this.endpointMode; + } + + @Override + public Ec2MetadataAsyncClient build() { + return new DefaultEc2MetadataAsyncClient(this); + } + } +} \ No newline at end of file diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClient.java new file mode 100644 index 000000000000..6129dd7b82f0 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClient.java @@ -0,0 +1,324 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static software.amazon.awssdk.imds.internal.RequestMarshaller.EC2_METADATA_TOKEN_TTL_HEADER; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import software.amazon.awssdk.annotations.Immutable; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.core.exception.RetryableException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder; +import software.amazon.awssdk.core.retry.RetryPolicyContext; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.HttpStatusFamily; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.imds.Ec2MetadataClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; +import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy; +import software.amazon.awssdk.imds.EndpointMode; +import software.amazon.awssdk.utils.Either; +import software.amazon.awssdk.utils.Logger; +import software.amazon.awssdk.utils.Validate; +import software.amazon.awssdk.utils.cache.CachedSupplier; +import software.amazon.awssdk.utils.cache.RefreshResult; + +/** + * An Implementation of the Ec2Metadata Interface. + */ +@SdkInternalApi +@Immutable +@ThreadSafe +public final class DefaultEc2MetadataClient extends BaseEc2MetadataClient implements Ec2MetadataClient { + + private static final Logger log = Logger.loggerFor(DefaultEc2MetadataClient.class); + + private final SdkHttpClient httpClient; + private final Supplier tokenCache; + private final boolean httpClientIsInternal; + + private DefaultEc2MetadataClient(Ec2MetadataBuilder builder) { + super(builder); + + Validate.isTrue(builder.httpClient == null || builder.httpClientBuilder == null, + "The httpClient and the httpClientBuilder can't both be configured."); + this.httpClient = Either + .fromNullable(builder.httpClient, builder.httpClientBuilder) + .map(e -> e.map(Function.identity(), SdkHttpClient.Builder::build)) + .orElseGet(() -> new DefaultSdkHttpClientBuilder().buildWithDefaults(IMDS_HTTP_DEFAULTS)); + this.httpClientIsInternal = builder.httpClient == null; + + this.tokenCache = CachedSupplier.builder(() -> RefreshResult.builder(this.getToken()) + .staleTime(Instant.now().plus(tokenTtl)) + .build()) + .build(); + } + + @Override + public void close() { + if (httpClientIsInternal) { + httpClient.close(); + } + } + + public static Ec2MetadataBuilder builder() { + return new DefaultEc2MetadataClient.Ec2MetadataBuilder(); + } + + /** + * Gets the specified instance metadata value by the given path. Will retry base on the + * {@link Ec2MetadataRetryPolicy retry policy} provided, in the case of an IOException during request. Will not retry on + * SdkClientException, like 4XX HTTP error. + * + * @param path Input path of the resource to get. + * @return Instance metadata value as part of MetadataResponse Object + * @throws SdkClientException if the request for a token or the request for the Metadata does not have a 2XX SUCCESS response, + * if the maximum number of retries is reached, or if another IOException is thrown during the + * request. + */ + @Override + public Ec2MetadataResponse get(String path) { + Throwable lastCause = null; + // 3 retries means 4 total attempts + Token token = null; + for (int attempt = 0; attempt < retryPolicy.numRetries() + 1; attempt++) { + RetryPolicyContext retryPolicyContext = RetryPolicyContext.builder().retriesAttempted(attempt).build(); + try { + if (token == null || token.isExpired()) { + token = tokenCache.get(); + } + return sendRequest(path, token.value()); + } catch (UncheckedIOException | RetryableException e) { + lastCause = e; + int currentTry = attempt; + log.debug(() -> "Error while executing EC2Metadata request, attempting retry. Current attempt: " + currentTry); + } catch (SdkClientException sdkClientException) { + int totalTries = attempt + 1; + log.debug(() -> String.format("Error while executing EC2Metadata request. Total attempts: %d. %s", + totalTries, + sdkClientException.getMessage())); + throw sdkClientException; + } catch (IOException ioe) { + lastCause = new UncheckedIOException(ioe); + int currentTry = attempt; + log.debug(() -> "Error while executing EC2Metadata request, attempting retry. Current attempt: " + currentTry); + } + pauseBeforeRetryIfNeeded(retryPolicyContext); + } + + SdkClientException.Builder sdkClientExceptionBuilder = SdkClientException + .builder() + .message("Exceeded maximum number of retries. Total retry attempts: " + retryPolicy.numRetries()); + if (lastCause != null) { + String msg = sdkClientExceptionBuilder.message(); + sdkClientExceptionBuilder.cause(lastCause).message(msg); + } + throw sdkClientExceptionBuilder.build(); + } + + private Ec2MetadataResponse sendRequest(String path, String token) throws IOException { + + HttpExecuteRequest httpExecuteRequest = + HttpExecuteRequest.builder() + .request(requestMarshaller.createDataRequest(path, token, tokenTtl)) + .build(); + HttpExecuteResponse response = httpClient.prepareRequest(httpExecuteRequest).call(); + + int statusCode = response.httpResponse().statusCode(); + Optional responseBody = response.responseBody(); + + if (HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SERVER_ERROR)) { + responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8) + .ifPresent(str -> log.debug(() -> "Metadata request response body: " + str)); + throw RetryableException + .builder() + .message(String.format("The requested metadata at path '%s' returned Http code %s", path, statusCode)) + .build(); + } + + if (!HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SUCCESSFUL)) { + responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8) + .ifPresent(str -> log.debug(() -> "Metadata request response body: " + str)); + throw SdkClientException + .builder() + .message(String.format("The requested metadata at path '%s' returned Http code %s", path, statusCode)) + .build(); + } + + AbortableInputStream abortableInputStream = responseBody.orElseThrow( + SdkClientException.builder().message("Response body empty with Status Code " + statusCode)::build); + String data = uncheckedInputStreamToUtf8(abortableInputStream); + return Ec2MetadataResponse.create(data); + } + + private void pauseBeforeRetryIfNeeded(RetryPolicyContext retryPolicyContext) { + long backoffTimeMillis = retryPolicy.backoffStrategy() + .computeDelayBeforeNextRetry(retryPolicyContext) + .toMillis(); + if (backoffTimeMillis > 0) { + try { + TimeUnit.MILLISECONDS.sleep(backoffTimeMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw SdkClientException.builder().message("Thread interrupted while trying to sleep").cause(e).build(); + } + } + } + + private Token getToken() { + HttpExecuteRequest httpExecuteRequest = HttpExecuteRequest.builder() + .request(requestMarshaller.createTokenRequest(tokenTtl)) + .build(); + HttpExecuteResponse response = null; + try { + response = httpClient.prepareRequest(httpExecuteRequest).call(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + int statusCode = response.httpResponse().statusCode(); + + if (HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SERVER_ERROR)) { + response.responseBody().map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8) + .ifPresent(str -> log.debug(() -> "Metadata request response body: " + str)); + throw RetryableException.builder() + .message("Could not retrieve token, " + statusCode + " error occurred").build(); + } + + if (!HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SUCCESSFUL)) { + response.responseBody().map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8) + .ifPresent(body -> log.debug(() -> "Token request response body: " + body)); + throw SdkClientException.builder() + .message("Could not retrieve token, " + statusCode + " error occurred.") + .build(); + } + + String ttl = response.httpResponse() + .firstMatchingHeader(EC2_METADATA_TOKEN_TTL_HEADER) + .orElseThrow(() -> SdkClientException + .builder() + .message(EC2_METADATA_TOKEN_TTL_HEADER + " header not found in token response") + .build()); + Duration ttlDuration; + try { + ttlDuration = Duration.ofSeconds(Long.parseLong(ttl)); + } catch (NumberFormatException nfe) { + throw SdkClientException.create("Invalid token format received from IMDS server", nfe); + } + + AbortableInputStream abortableInputStream = response.responseBody().orElseThrow( + SdkClientException.builder().message("Empty response body")::build); + + String value = uncheckedInputStreamToUtf8(abortableInputStream); + return new Token(value, ttlDuration); + } + + protected static final class Ec2MetadataBuilder implements Ec2MetadataClient.Builder { + + private Ec2MetadataRetryPolicy retryPolicy; + + private URI endpoint; + + private Duration tokenTtl; + + private EndpointMode endpointMode; + + private SdkHttpClient httpClient; + + private SdkHttpClient.Builder httpClientBuilder; + + private Ec2MetadataBuilder() { + } + + @Override + public Ec2MetadataBuilder retryPolicy(Ec2MetadataRetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + return this; + } + + @Override + public Builder retryPolicy(Consumer builderConsumer) { + Validate.notNull(builderConsumer, "builderConsumer must not be null"); + Ec2MetadataRetryPolicy.Builder builder = Ec2MetadataRetryPolicy.builder(); + builderConsumer.accept(builder); + return retryPolicy(builder.build()); + } + + @Override + public Ec2MetadataBuilder endpoint(URI endpoint) { + this.endpoint = endpoint; + return this; + } + + @Override + public Ec2MetadataBuilder tokenTtl(Duration tokenTtl) { + this.tokenTtl = tokenTtl; + return this; + } + + @Override + public Ec2MetadataBuilder endpointMode(EndpointMode endpointMode) { + this.endpointMode = endpointMode; + return this; + } + + @Override + public Ec2MetadataBuilder httpClient(SdkHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + @Override + public Builder httpClient(SdkHttpClient.Builder builder) { + this.httpClientBuilder = builder; + return this; + } + + public Ec2MetadataRetryPolicy getRetryPolicy() { + return this.retryPolicy; + } + + public URI getEndpoint() { + return this.endpoint; + } + + public Duration getTokenTtl() { + return this.tokenTtl; + } + + public EndpointMode getEndpointMode() { + return this.endpointMode; + } + + @Override + public Ec2MetadataClient build() { + return new DefaultEc2MetadataClient(this); + } + } +} \ No newline at end of file diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Ec2MetadataEndpointProvider.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Ec2MetadataEndpointProvider.java new file mode 100644 index 000000000000..96f2df45d9a0 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Ec2MetadataEndpointProvider.java @@ -0,0 +1,150 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static software.amazon.awssdk.imds.EndpointMode.IPV4; +import static software.amazon.awssdk.imds.EndpointMode.IPV6; + +import java.util.EnumMap; +import java.util.Optional; +import java.util.function.Supplier; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.imds.EndpointMode; +import software.amazon.awssdk.profiles.Profile; +import software.amazon.awssdk.profiles.ProfileFile; +import software.amazon.awssdk.profiles.ProfileFileSystemSetting; +import software.amazon.awssdk.profiles.ProfileProperty; +import software.amazon.awssdk.utils.Validate; + +/** + * Endpoint Provider Class which contains methods for endpoint resolution. + */ +@SdkInternalApi +public final class Ec2MetadataEndpointProvider { + + private static final Ec2MetadataEndpointProvider DEFAULT_ENDPOINT_PROVIDER = builder().build(); + + private static final EnumMap DEFAULT_ENDPOINT_MODE; + + static { + DEFAULT_ENDPOINT_MODE = new EnumMap<>(EndpointMode.class); + DEFAULT_ENDPOINT_MODE.put(IPV4, "http://169.254.169.254"); + DEFAULT_ENDPOINT_MODE.put(IPV6, "http://[fd00:ec2::254]"); + } + + private final Supplier profileFile; + private final String profileName; + + private Ec2MetadataEndpointProvider(Builder builder) { + this.profileFile = builder.profileFile; + this.profileName = builder.profileName; + } + + public static Ec2MetadataEndpointProvider instance() { + return DEFAULT_ENDPOINT_PROVIDER; + } + + /** + * Resolve the endpoint to be used for the {@link DefaultEc2MetadataClient} client. Users may manually provide an endpoint + * through the {@code AWS_EC2_METADATA_SERVICE_ENDPOINT} environment variable or the {@code ec2_metadata_service_endpoint} + * key in their aws config file. + * If an endpoint is specified is this manner, use it. If no value are provide, the defaults to: + *
    + *
  1. If endpoint mode is set to IPv4: {@code "http://169.254.169.254"}
  2. + *
  3. If endpoint mode is set to IPv6: {@code "http://[fd00:ec2::254]"}
  4. + *
+ * (the default endpoint mode is IPV4). + * @param endpointMode Used only if an endpoint value is not specified. If so, this method will use the endpointMode to + * choose the default value to return. + * @return the String representing the endpoint to be used, + */ + public String resolveEndpoint(EndpointMode endpointMode) { + Optional endpointFromSystem = SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.getNonDefaultStringValue(); + if (endpointFromSystem.isPresent()) { + return stripEndingSlash(endpointFromSystem.get()); + } + + Optional endpointFromConfigProfile = resolveProfile() + .flatMap(profile -> profile.property(ProfileProperty.EC2_METADATA_SERVICE_ENDPOINT)); + if (endpointFromConfigProfile.isPresent()) { + return stripEndingSlash(endpointFromConfigProfile.get()); + } + + Validate.notNull(endpointMode, "endpointMode must not be null."); + return endpointFromConfigProfile.orElseGet(() -> DEFAULT_ENDPOINT_MODE.get(endpointMode)); + } + + private static String stripEndingSlash(String uri) { + return uri.endsWith("/") + ? uri.substring(0, uri.length() - 1) + : uri; + } + + public EndpointMode resolveEndpointMode() { + Optional systemEndpointMode = SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE.getNonDefaultStringValue(); + if (systemEndpointMode.isPresent()) { + return EndpointMode.fromValue(systemEndpointMode.get()); + } + + Optional configFileEndPointMode = resolveProfile() + .flatMap(p -> p.property(ProfileProperty.EC2_METADATA_SERVICE_ENDPOINT_MODE)) + .map(EndpointMode::fromValue); + if (configFileEndPointMode.isPresent()) { + return configFileEndPointMode.get(); + } + + String defaultSystemEndpointMode = SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE.defaultValue(); + return EndpointMode.fromValue(defaultSystemEndpointMode); + } + + public Optional resolveProfile() { + String profileNameToUse = profileName == null + ? ProfileFileSystemSetting.AWS_PROFILE.getStringValueOrThrow() + : profileName; + ProfileFile profileFileToUse = profileFile.get(); + return profileFileToUse.profile(profileNameToUse); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Supplier profileFile; + private String profileName; + + private Builder() { + this.profileFile = ProfileFile::defaultProfileFile; + } + + public Builder profileFile(Supplier profileFile) { + Validate.notNull(profileFile, "profileFile Supplier must not be null"); + this.profileFile = profileFile; + return this; + } + + public Builder profileName(String profileName) { + this.profileName = profileName; + return this; + } + + public Ec2MetadataEndpointProvider build() { + return new Ec2MetadataEndpointProvider(this); + } + } + +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java new file mode 100644 index 000000000000..dde1dc2e4941 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java @@ -0,0 +1,76 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import java.net.URI; +import java.time.Duration; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.util.SdkUserAgent; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; + +/** + * Class to parse the parameters to a SdkHttpRequest , make the call to the endpoint and send the HttpExecuteResponse + * to the DefaultEc2Metadata class for further processing. + */ +@SdkInternalApi +public class RequestMarshaller { + + public static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; + + public static final String TOKEN_HEADER = "x-aws-ec2-metadata-token"; + + public static final String EC2_METADATA_TOKEN_TTL_HEADER = "x-aws-ec2-metadata-token-ttl-seconds"; + + public static final String USER_AGENT = "user_agent"; + + public static final String ACCEPT = "Accept"; + + public static final String CONNECTION = "connection"; + + private final URI basePath; + private final URI tokenPath; + + public RequestMarshaller(URI basePath) { + this.basePath = basePath; + this.tokenPath = URI.create(basePath + TOKEN_RESOURCE_PATH); + } + + public SdkHttpFullRequest createTokenRequest(Duration tokenTtl) { + return defaulttHttpBuilder() + .method(SdkHttpMethod.PUT) + .uri(tokenPath) + .putHeader(EC2_METADATA_TOKEN_TTL_HEADER, String.valueOf(tokenTtl.getSeconds())) + .build(); + } + + public SdkHttpFullRequest createDataRequest(String path, String token, Duration tokenTtl) { + URI resourcePath = URI.create(basePath + path); + return defaulttHttpBuilder() + .method(SdkHttpMethod.GET) + .uri(resourcePath) + .putHeader(EC2_METADATA_TOKEN_TTL_HEADER, String.valueOf(tokenTtl.getSeconds())) + .putHeader(TOKEN_HEADER, token) + .build(); + } + + private SdkHttpFullRequest.Builder defaulttHttpBuilder() { + return SdkHttpFullRequest.builder() + .putHeader(USER_AGENT, SdkUserAgent.create().userAgent()) + .putHeader(ACCEPT, "*/*") + .putHeader(CONNECTION, "keep-alive"); + } +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Token.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Token.java new file mode 100644 index 000000000000..1a54fedbc840 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Token.java @@ -0,0 +1,90 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import software.amazon.awssdk.annotations.Immutable; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.utils.ToString; + +@SdkInternalApi +@Immutable +public final class Token { + private final String value; + private final Duration ttl; + private final Instant createdTime; + + public Token(String value, Duration ttl) { + this.value = value; + this.ttl = ttl; + this.createdTime = Instant.now(); + } + + public String value() { + return value; + } + + public Duration ttl() { + return ttl; + } + + public Instant createdTime() { + return createdTime; + } + + public boolean isExpired() { + return Instant.now().isAfter(createdTime.plus(ttl)); + } + + @Override + public String toString() { + return ToString.builder("Token") + .add("value", value) + .add("ttl", ttl) + .add("createdTime", createdTime) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Token token = (Token) o; + + if (!Objects.equals(value, token.value)) { + return false; + } + if (!Objects.equals(ttl, token.ttl)) { + return false; + } + return Objects.equals(createdTime, token.createdTime); + } + + @Override + public int hashCode() { + int result = value != null ? value.hashCode() : 0; + result = 31 * result + (ttl != null ? ttl.hashCode() : 0); + result = 31 * result + (createdTime != null ? createdTime.hashCode() : 0); + return result; + } +} diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/unmarshall/document/DocumentUnmarshaller.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/unmarshall/document/DocumentUnmarshaller.java new file mode 100644 index 000000000000..16db2588a327 --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/unmarshall/document/DocumentUnmarshaller.java @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal.unmarshall.document; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.document.Document; +import software.amazon.awssdk.protocols.jsoncore.JsonNode; +import software.amazon.awssdk.protocols.jsoncore.JsonNodeVisitor; + +@SdkInternalApi +public final class DocumentUnmarshaller implements JsonNodeVisitor { + @Override + public Document visitNull() { + return Document.fromNull(); + } + + @Override + public Document visitBoolean(boolean bool) { + return Document.fromBoolean(bool); + } + + @Override + public Document visitNumber(String number) { + return Document.fromNumber(number); + } + + @Override + public Document visitString(String string) { + return Document.fromString(string); + } + + @Override + public Document visitArray(List array) { + return Document.fromList(array.stream() + .map(node -> node.visit(this)) + .collect(Collectors.toList())); + } + + @Override + public Document visitObject(Map object) { + return Document.fromMap(object.entrySet() + .stream().collect(Collectors.toMap(Map.Entry::getKey, + entry -> entry.getValue().visit(this), + (left, right) -> left, + LinkedHashMap::new))); + } + + @Override + public Document visitEmbeddedObject(Object embeddedObject) { + throw new UnsupportedOperationException("Embedded objects are not supported within Document types."); + } +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataResponseTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataResponseTest.java new file mode 100644 index 000000000000..ed5d35a6c81f --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataResponseTest.java @@ -0,0 +1,106 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.document.Document; +import software.amazon.awssdk.thirdparty.jackson.core.JsonParseException; + +/** + * The class tests the utility methods provided by MetadataResponse Class . + */ +class Ec2MetadataResponseTest { + + @Test + void check_asString_success() { + + String response = "foobar"; + + Ec2MetadataResponse metadataResponse = Ec2MetadataResponse.create(response); + String result = metadataResponse.asString(); + assertThat(result).isEqualTo(response); + + } + + @Test + void check_asString_failure() { + assertThatThrownBy(() -> Ec2MetadataResponse.create(null)).isInstanceOf(NullPointerException.class); + } + + @Test + void check_asList_success_with_delimiter() { + + String response = "sai\ntest"; + + Ec2MetadataResponse metadataResponse = Ec2MetadataResponse.create(response); + List result = metadataResponse.asList(); + assertThat(result).hasSize(2); + } + + @Test + void check_asList_success_without_delimiter() { + + String response = "test1-test2"; + + Ec2MetadataResponse metadataResponse = Ec2MetadataResponse.create(response); + List result = metadataResponse.asList(); + assertThat(result).hasSize(1); + } + + @Test + void check_asDocument_success() { + String jsonResponse = "{" + + "\"instanceType\":\"m1.small\"," + + "\"devpayProductCodes\":[\"bar\",\"foo\"]" + + "}"; + + Ec2MetadataResponse metadataResponse = Ec2MetadataResponse.create(jsonResponse); + Document document = metadataResponse.asDocument(); + Map expectedMap = new LinkedHashMap<>(); + + List documentList = new ArrayList<>(); + documentList.add(Document.fromString("bar")); + documentList.add(Document.fromString("foo")); + + expectedMap.put("instanceType", Document.fromString("m1.small")); + expectedMap.put("devpayProductCodes", Document.fromList(documentList)); + Document expectedDocumentMap = Document.fromMap(expectedMap); + assertThat(document).isEqualTo(expectedDocumentMap); + } + + @Test + void toDocument_nonJsonFormat_ExpectIllegalArgument() { + String malformed = "this is not json"; + Ec2MetadataResponse metadataResponse = Ec2MetadataResponse.create(malformed); + assertThatThrownBy(metadataResponse::asDocument).getCause().isInstanceOf(JsonParseException.class); + } + + @Test + void equals_hasCode() { + Ec2MetadataResponse metadataResponse = Ec2MetadataResponse.create("Line 1"); + assertThat(metadataResponse).isEqualTo(Ec2MetadataResponse.create("Line 1")) + .hasSameHashCodeAs("Line 1"); + assertThat(metadataResponse.equals(null)).isFalse(); + } + +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicyTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicyTest.java new file mode 100644 index 000000000000..7131127b935d --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicyTest.java @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; + +class Ec2MetadataRetryPolicyTest { + + @Test + void equals_hashCode() { + BackoffStrategy backoffStrategy = BackoffStrategy.defaultStrategy(); + Ec2MetadataRetryPolicy policy = Ec2MetadataRetryPolicy.builder() + .numRetries(3) + .backoffStrategy(backoffStrategy) + .build(); + assertThat(policy).isEqualTo(Ec2MetadataRetryPolicy.builder() + .numRetries(3) + .backoffStrategy(backoffStrategy) + .build()); + } + + @Test + void builder_setNumRetriesCorrectly() { + Ec2MetadataRetryPolicy policy = Ec2MetadataRetryPolicy.builder() + .numRetries(3) + .build(); + assertThat(policy.numRetries()).isEqualTo(3); + } +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/EndpointModeTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/EndpointModeTest.java new file mode 100644 index 000000000000..c880c3dd2d72 --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/EndpointModeTest.java @@ -0,0 +1,46 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Unit Tests to test the EndpointMode enum functionality. + */ +class EndpointModeTest { + + @Test + void verifyFromValue_when_nullParameterIsPassed(){ + assertThat(EndpointMode.fromValue(null)).isEqualTo(null); + } + + @Test + void verifyFromValue_when_normalParameterIsPassed(){ + assertThat(EndpointMode.fromValue("ipv4")).isEqualTo(EndpointMode.IPV4); + } + + @Test + void verifyFromValue_when_wrongParameterIsPassed(){ + assertThatThrownBy(() -> { + EndpointMode.fromValue("ipv8"); + }).hasMessageContaining("Unrecognized value for endpoint mode") + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/TestConstants.java b/core/imds/src/test/java/software/amazon/awssdk/imds/TestConstants.java new file mode 100644 index 000000000000..dd342025d678 --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/TestConstants.java @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds; + +public class TestConstants { + public static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; + public static final String TOKEN_HEADER = "x-aws-ec2-metadata-token"; + public static final String EC2_METADATA_TOKEN_TTL_HEADER = "x-aws-ec2-metadata-token-ttl-seconds"; + public static final String EC2_METADATA_ROOT = "/latest/meta-data"; + public static final String AMI_ID_RESOURCE = EC2_METADATA_ROOT + "/ami-id"; +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClientTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClientTest.java new file mode 100644 index 000000000000..96f95d16ec8f --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClientTest.java @@ -0,0 +1,316 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static software.amazon.awssdk.imds.EndpointMode.IPV4; +import static software.amazon.awssdk.imds.EndpointMode.IPV6; +import static software.amazon.awssdk.imds.TestConstants.AMI_ID_RESOURCE; +import static software.amazon.awssdk.imds.TestConstants.EC2_METADATA_TOKEN_TTL_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_RESOURCE_PATH; + +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.time.Duration; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.retry.backoff.FixedDelayBackoffStrategy; +import software.amazon.awssdk.imds.Ec2MetadataClientBuilder; +import software.amazon.awssdk.imds.Ec2MetadataResponse; +import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy; +import software.amazon.awssdk.imds.EndpointMode; + +@WireMockTest +abstract class BaseEc2MetadataClientTest> { + + protected static final int DEFAULT_TOTAL_ATTEMPTS = 4; + + protected abstract BaseEc2MetadataClient overrideClient(Consumer builderConsumer); + + protected abstract void successAssertions(String path, Consumer assertions); + + protected abstract void failureAssertions(String path, Class exceptionType, + Consumer assertions); + + protected abstract int getPort(); + + @Test + void get_successOnFirstTry_shouldNotRetryAndSucceed() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); + successAssertions(AMI_ID_RESOURCE, response -> { + assertThat(response.asString()).isEqualTo("{}"); + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(1), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void get_failsEverytime_shouldRetryAndFails() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withStatus(500).withBody("Error 500"))); + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, ex -> { + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(DEFAULT_TOTAL_ATTEMPTS), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void get_returnsStatus4XX_shouldFailsAndNotRetry() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withStatus(400).withBody("error"))); + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, ex -> { + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(1), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void get_failsOnceThenSucceed_withCustomClient_shouldSucceed() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)) + .inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(500).withBody("Error 500")) + .willSetStateTo("Cause Success")); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("{}"))); + + overrideClient(builder -> builder + .retryPolicy(Ec2MetadataRetryPolicy.builder() + .numRetries(5) + .backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofMillis(300))) + .build()) + .endpoint(URI.create("http://localhost:" + getPort())) + .tokenTtl(Duration.ofSeconds(1024))); + + successAssertions(AMI_ID_RESOURCE, response -> { + assertThat(response.asString()).isEqualTo("{}"); + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("1024"))); + verify(exactly(2), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void getToken_failsEverytime_shouldRetryAndFailsAndNotCallService() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withStatus(500).withBody("Error 500"))); + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, ex -> { + verify(exactly(DEFAULT_TOTAL_ATTEMPTS), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(0), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void getToken_returnsStatus4XX_shouldFailsAndNotRetry() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withStatus(400).withBody("ERROR 400"))); + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, ex -> { + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(0), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void getToken_failsOnceThenSucceed_withCustomClient_shouldSucceed() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(500).withBody("Error 500")) + .willSetStateTo("Cause Success")); + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn( + aResponse() + .withBody("some-token") + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("Success"))); + + overrideClient(builder -> builder + .retryPolicy(Ec2MetadataRetryPolicy.builder() + .numRetries(5) + .backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofMillis(300))) + .build()) + .endpoint(URI.create("http://localhost:" + getPort())) + .build()); + + successAssertions(AMI_ID_RESOURCE, response -> { + assertThat(response.asString()).isEqualTo("Success"); + verify(exactly(2), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(1), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void get_noRetries_shouldNotRetry() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withStatus(500).withBody("Error 500"))); + + overrideClient(builder -> builder + .endpoint(URI.create("http://localhost:" + getPort())) + .retryPolicy(Ec2MetadataRetryPolicy.none()).build()); + + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, ex -> { + verify(1, putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(1, putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + }); + } + + @Test + void builder_endpointAndEndpointModeSpecified_shouldThrowIllegalArgException() { + assertThatThrownBy(() -> overrideClient(builder -> builder + .endpoint(URI.create("http://localhost:" + getPort())) + .endpointMode(IPV6))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void builder_defaultValue_clientShouldUseIPV4Endpoint() { + BaseEc2MetadataClient client = overrideClient(builder -> { + }); + assertThat(client.endpoint).hasToString("http://169.254.169.254"); + } + + @Test + void builder_setEndpoint_shouldUseEndpoint() { + BaseEc2MetadataClient client = overrideClient(builder -> builder.endpoint(URI.create("http://localhost:" + 12312))); + assertThat(client.endpoint).hasToString("http://localhost:" + 12312); + } + + @ParameterizedTest + @MethodSource("endpointArgumentSource") + void builder_setEndPointMode_shouldUseEndpointModeValue(EndpointMode endpointMode, String value) { + BaseEc2MetadataClient client = overrideClient(builder -> builder.endpointMode(endpointMode)); + assertThat(client.endpoint).hasToString(value); + } + + private static Stream endpointArgumentSource() { + return Stream.of( + arguments(IPV4, "http://169.254.169.254"), + arguments(IPV6, "http://[fd00:ec2::254]")); + } + + @Test + void get_tokenExpiresWhileRetrying_shouldSucceedWithNewToken() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "2"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(500) + .withBody("Error 500") + .withFixedDelay(700)) + .willSetStateTo("Retry-1")); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Retry-1") + .willReturn(aResponse().withStatus(500) + .withBody("Error 500") + .withFixedDelay(700)) + .willSetStateTo("Retry-2")); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Retry-2") + .willReturn(aResponse().withStatus(500) + .withBody("Error 500") + .withFixedDelay(700)) + .willSetStateTo("Retry-3")); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Retry-3") + .willReturn(aResponse().withStatus(200) + .withBody("Success") + .withFixedDelay(700))); + + overrideClient(builder -> builder + .tokenTtl(Duration.ofSeconds(2)) + .endpoint(URI.create("http://localhost:" + getPort())) + .build()); + + successAssertions(AMI_ID_RESOURCE, response -> { + assertThat(response.asString()).isEqualTo("Success"); + verify(exactly(2), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("2"))); + verify(exactly(4), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void getToken_responseWithoutTtlHeaders_shouldFailAndNotRetry() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token"))); // no EC2_METADATA_TOKEN_TTL_HEADER header + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withStatus(200).withBody("some-value"))); + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, e -> { + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(0), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } + + @Test + void getToken_responseTtlHeadersNotANumber_shouldFailAndNotRetry() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withStatus(200).withBody("some-value"))); + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "not-a-number"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withStatus(200).withBody("some-value"))); + failureAssertions(AMI_ID_RESOURCE, SdkClientException.class, e -> { + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(0), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + }); + } +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/CachedTokenClientTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/CachedTokenClientTest.java new file mode 100644 index 000000000000..04ad39dc23e9 --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/CachedTokenClientTest.java @@ -0,0 +1,125 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.imds.TestConstants.EC2_METADATA_TOKEN_TTL_HEADER; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.imds.Ec2MetadataClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; + +@WireMockTest +class CachedTokenClientTest { + + private Ec2MetadataClient.Builder clientBuilder; + + @BeforeEach + void init(WireMockRuntimeInfo wmRuntimeInfo) { + this.clientBuilder = Ec2MetadataClient.builder() + .endpoint(URI.create("http://localhost:" + wmRuntimeInfo.getHttpPort())); + } + + @Test + void get_tokenFailsError4xx_shouldNotRetry() { + stubFor(put(urlPathEqualTo("/latest/api/token")).willReturn(aResponse().withStatus(400).withBody("ERROR 400"))); + stubFor(get(urlPathEqualTo("/latest/meta-data/ami-id")).willReturn(aResponse().withBody("{}"))); + + assertThatThrownBy(() -> clientBuilder.build().get("/latest/meta-data/ami-id")).isInstanceOf(SdkClientException.class); + verify(exactly(1), putRequestedFor(urlPathEqualTo("/latest/api/token")) + .withHeader("x-aws-ec2-metadata-token-ttl-seconds", equalTo("21600"))); + } + + @Test + void getToken_failsError5xx_shouldRetryUntilMaxRetriesIsReached() { + stubFor(put(urlPathEqualTo("/latest/api/token")).willReturn(aResponse().withStatus(500).withBody("ERROR 500"))); + stubFor(get(urlPathEqualTo("/latest/meta-data/ami-id")).willReturn(aResponse().withBody("{}"))); + + assertThatThrownBy(() -> clientBuilder.build().get("/latest/meta-data/ami-id")).isInstanceOf(SdkClientException.class); + verify(exactly(4), putRequestedFor(urlPathEqualTo("/latest/api/token")) + .withHeader("x-aws-ec2-metadata-token-ttl-seconds", equalTo("21600"))); + } + + @Test + void getToken_failsThenSucceeds_doesCacheTokenThatSucceeds() { + stubFor(put(urlPathEqualTo("/latest/api/token")).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(500).withBody("Error 500")) + .willSetStateTo("Cause Success")); + stubFor(put(urlPathEqualTo("/latest/api/token")).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse() + .withBody("token-ok") + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo("/latest/meta-data/ami-id")).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("Success"))); + + // 3 requests + Ec2MetadataClient client = clientBuilder.build(); + Ec2MetadataResponse response = client.get("/latest/meta-data/ami-id"); + assertThat(response.asString()).isEqualTo("Success"); + response = client.get("/latest/meta-data/ami-id"); + assertThat(response.asString()).isEqualTo("Success"); + response = client.get("/latest/meta-data/ami-id"); + assertThat(response.asString()).isEqualTo("Success"); + + verify(exactly(2), putRequestedFor(urlPathEqualTo("/latest/api/token")) + .withHeader("x-aws-ec2-metadata-token-ttl-seconds", equalTo("21600"))); + verify(exactly(3), getRequestedFor(urlPathEqualTo("/latest/meta-data/ami-id")) + .withHeader("x-aws-ec2-metadata-token", equalTo("token-ok"))); + } + + @Test + void get_multipleCallsSuccess_shouldReuseToken() throws Exception { + stubFor(put(urlPathEqualTo("/latest/api/token")).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo("/latest/meta-data/ami-id")) + .willReturn(aResponse().withBody("{}").withFixedDelay(800))); + + int tokenTTlSeconds = 4; + Ec2MetadataClient client = clientBuilder.tokenTtl(Duration.ofSeconds(tokenTTlSeconds)).build(); + + int totalRequests = 10; + for (int i = 0; i < totalRequests; i++) { + Ec2MetadataResponse response = client.get("/latest/meta-data/ami-id"); + assertThat(response.asString()).isEqualTo("{}"); + } + verify(exactly(2), putRequestedFor(urlPathEqualTo("/latest/api/token")) + .withHeader("x-aws-ec2-metadata-token-ttl-seconds", equalTo(String.valueOf(tokenTTlSeconds)))); + verify(exactly(totalRequests), getRequestedFor(urlPathEqualTo("/latest/meta-data/ami-id")) + .withHeader("x-aws-ec2-metadata-token", equalTo("some-token"))); + } + +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/Ec2MetadataAsyncClientTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/Ec2MetadataAsyncClientTest.java new file mode 100644 index 000000000000..f6bfea862ada --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/Ec2MetadataAsyncClientTest.java @@ -0,0 +1,134 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.awssdk.imds.TestConstants.AMI_ID_RESOURCE; +import static software.amazon.awssdk.imds.TestConstants.EC2_METADATA_TOKEN_TTL_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_RESOURCE_PATH; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.internal.http.loader.DefaultSdkAsyncHttpClientBuilder; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.imds.Ec2MetadataAsyncClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; + +@WireMockTest +class Ec2MetadataAsyncClientTest extends BaseEc2MetadataClientTest { + + private Ec2MetadataAsyncClient client; + + private int port; + + @BeforeEach + public void init(WireMockRuntimeInfo wiremock) { + this.port = wiremock.getHttpPort(); + this.client = Ec2MetadataAsyncClient.builder() + .endpoint(URI.create("http://localhost:" + wiremock.getHttpPort())) + .build(); + } + + @Override + protected int getPort() { + return port; + } + + @Override + protected BaseEc2MetadataClient overrideClient(Consumer builderConsumer) { + Ec2MetadataAsyncClient.Builder builder = Ec2MetadataAsyncClient.builder(); + builderConsumer.accept(builder); + this.client = builder.build(); + return (BaseEc2MetadataClient) this.client; + } + + @Override + protected void successAssertions(String path, Consumer assertions) { + CompletableFuture response = client.get(path); + try { + assertions.accept(response.join()); + } catch (Exception e) { + fail("unexpected error while exeucting tests", e); + } + } + + @Override + @SuppressWarnings("unchecked") // safe because of assertion: assertThat(ex).getCause().isInstanceOf(exceptionType); + protected void failureAssertions(String path, Class exceptionType, Consumer assertions) { + CompletableFuture future = client.get(path); + Throwable ex = catchThrowable(future::join); + assertThat(future).isCompletedExceptionally(); + assertThat(ex).getCause().isInstanceOf(exceptionType); + assertions.accept((T) ex.getCause()); + } + + @Test + void get_cancelResponseFuture_shouldCancelHttpRequest() { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withFixedDelay(1000))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn( + aResponse().withBody("some-content").withFixedDelay(1000))); + + CompletableFuture responseFuture = client.get(AMI_ID_RESOURCE); + try { + responseFuture.cancel(true); + responseFuture.join(); + } catch (CancellationException e) { + // ignore java.util.concurrent.CancellationException + } + verify(0, getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE))); + } + + @Test + void builder_httpClientWithDefaultBuilder_shouldBuildProperly() { + Ec2MetadataAsyncClient buildClient = Ec2MetadataAsyncClient.builder() + .httpClient(new DefaultSdkAsyncHttpClientBuilder()) + .endpoint(URI.create("http://localhost:" + port)) + .build(); + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("some-value"))); + CompletableFuture responseFuture = buildClient.get(AMI_ID_RESOURCE); + Ec2MetadataResponse response = responseFuture.join(); + assertThat(response.asString()).isEqualTo("some-value"); + } + + @Test + void builder_httpClientAndHttpBuilder_shouldThrowException() { + assertThatThrownBy(() -> Ec2MetadataAsyncClient.builder() + .httpClient(new DefaultSdkAsyncHttpClientBuilder()) + .httpClient(NettyNioAsyncHttpClient.create()) + .build()) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/Ec2MetadataClientTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/Ec2MetadataClientTest.java new file mode 100644 index 000000000000..4cf8b1b203e7 --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/Ec2MetadataClientTest.java @@ -0,0 +1,105 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static software.amazon.awssdk.imds.TestConstants.AMI_ID_RESOURCE; +import static software.amazon.awssdk.imds.TestConstants.EC2_METADATA_TOKEN_TTL_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_RESOURCE_PATH; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.imds.Ec2MetadataClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; + +@WireMockTest +class Ec2MetadataClientTest extends BaseEc2MetadataClientTest { + + private Ec2MetadataClient client; + + private int port; + + @BeforeEach + public void init(WireMockRuntimeInfo wiremock) { + this.port = wiremock.getHttpPort(); + this.client = Ec2MetadataClient.builder() + .endpoint(URI.create("http://localhost:" + wiremock.getHttpPort())) + .build(); + } + + @Override + protected int getPort() { + return port; + } + + @Override + protected BaseEc2MetadataClient overrideClient(Consumer builderConsumer) { + Ec2MetadataClient.Builder builder = Ec2MetadataClient.builder(); + builderConsumer.accept(builder); + this.client = builder.build(); + return (BaseEc2MetadataClient) this.client; + } + + @Override + protected void successAssertions(String path, Consumer assertions) { + Ec2MetadataResponse response = client.get(path); + assertions.accept(response); + } + + @Override + @SuppressWarnings("unchecked") // safe because of assertion: assertThat(ex).isInstanceOf(exceptionType); + protected void failureAssertions(String path, Class exceptionType, Consumer assertions) { + Throwable ex = catchThrowable(() -> client.get(path)); + assertThat(ex).isInstanceOf(exceptionType); + assertions.accept((T) ex); + } + + @Test + void builder_httpClientWithDefaultBuilder_shouldBuildProperly() { + Ec2MetadataClient buildClient = Ec2MetadataClient.builder() + .httpClient(new DefaultSdkHttpClientBuilder()) + .endpoint(URI.create("http://localhost:" + port)) + .build(); + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); + Ec2MetadataResponse response = buildClient.get(AMI_ID_RESOURCE); + assertThat(response.asString()).isEqualTo("{}"); + } + + @Test + void builder_httpClientAndHttpBuilder_shouldThrowException() { + assertThatThrownBy(() -> Ec2MetadataClient.builder() + .httpClient(new DefaultSdkHttpClientBuilder()) + .httpClient(UrlConnectionHttpClient.create()) + .build()) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/EndpointProviderTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/EndpointProviderTest.java new file mode 100644 index 000000000000..114049cdb1dc --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/EndpointProviderTest.java @@ -0,0 +1,165 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static software.amazon.awssdk.imds.EndpointMode.IPV4; +import static software.amazon.awssdk.imds.EndpointMode.IPV6; + +import java.net.URISyntaxException; +import java.nio.file.Paths; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.imds.EndpointMode; +import software.amazon.awssdk.profiles.ProfileFile; +import software.amazon.awssdk.profiles.ProfileFileSystemSetting; +import software.amazon.awssdk.testutils.EnvironmentVariableHelper; + +/** + * Test Class to test the endpoint resolution functionality. + */ +class EndpointProviderTest { + + private EnvironmentVariableHelper settingsHelper; + + @BeforeEach + void init() { + settingsHelper = new EnvironmentVariableHelper(); + } + + @AfterEach + void reset() { + settingsHelper.reset(); + System.clearProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE.property()); + System.clearProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property()); + } + + private static Stream provideEndpointAndEndpointModes() { + String testIpv4Url = "http://90:90:90:90"; + String testIpv6Url = "http://[9876:ec2::123]"; + return Stream.of( + arguments(null, true, testIpv4Url, true, "testIPv6", testIpv4Url), + arguments(null, true, testIpv6Url, true, "testIPv4", testIpv6Url), + + arguments(null, true, testIpv4Url, false, "testIPv6", testIpv4Url), + arguments(null, true, testIpv6Url, false, "testIPv4", testIpv6Url), + + arguments(null, false, "unused", true, "testIPv6", "[1234:ec2::456]"), + arguments(null, false, "unused", true, "testIPv4", "http://42.42.42.42"), + + arguments(IPV4, false, "unused", false, "unused", "http://169.254.169.254"), + arguments(IPV6, false, "unused", false, "unused", "http://[fd00:ec2::254]") + ); + } + + @ParameterizedTest + @MethodSource("provideEndpointAndEndpointModes") + void validateResolveEndpoint(EndpointMode endpointMode, + boolean setEnvVariable, + String envEndpoint, + boolean setConfigFile, + String profile, + String expectedValue) + throws URISyntaxException { + + if (setEnvVariable) { + System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property(), envEndpoint); + } + + if (setConfigFile) { + String testFile = "/profile-config/test-profiles.tst"; + settingsHelper.set(ProfileFileSystemSetting.AWS_PROFILE, profile); + settingsHelper.set(ProfileFileSystemSetting.AWS_CONFIG_FILE, + Paths.get(getClass().getResource(testFile).toURI()).toString()); + } + + Ec2MetadataEndpointProvider endpointProvider = Ec2MetadataEndpointProvider.builder().build(); + String endpoint = endpointProvider.resolveEndpoint(endpointMode); + assertThat(endpoint).isEqualTo(expectedValue); + } + + private static Stream provideEndpointModes() { + return Stream.of( + arguments(false, "unused", false, "unused", IPV4), + arguments(true, "IPv4", true, "IPv4", IPV4), + arguments(true, "IPv6", true, "IPv6", IPV6), + arguments(true, "IPv6", true, "IPv4", IPV6), + arguments(true, "IPv4", true, "IPv6", IPV4), + arguments(false, "unused", true, "IPv6", IPV6), + arguments(false, "unused", true, "IPv4", IPV4), + arguments(true, "IPv6", false, "unused", IPV6), + arguments(true, "IPv4", false, "unused", IPV4) + ); + } + + @ParameterizedTest + @MethodSource("provideEndpointModes") + void endpointModeCheck(boolean useEnvVariable, String envVarValue, boolean useConfigFile, String configFileValue, + EndpointMode expectedValue) + throws URISyntaxException { + + if (useEnvVariable) { + System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE.property(), envVarValue); + } + + if (useConfigFile) { + String testFile = "/profile-config/test-profiles.tst"; + settingsHelper.set(ProfileFileSystemSetting.AWS_PROFILE, "test" + configFileValue); + settingsHelper.set(ProfileFileSystemSetting.AWS_CONFIG_FILE, + Paths.get(getClass().getResource(testFile).toURI()).toString()); + } + + Ec2MetadataEndpointProvider endpointProvider = Ec2MetadataEndpointProvider.builder().build(); + EndpointMode endpointMode = endpointProvider.resolveEndpointMode(); + + assertThat(endpointMode).isEqualTo(expectedValue); + } + + @Test + void endpointFromBuilder_withIpv4_shouldBesetCorrectly() { + ProfileFile.Builder content = ProfileFile.builder() + .type(ProfileFile.Type.CONFIGURATION) + .content(Paths.get("src/test/resources/profile-config/test-profiles.tst")); + Ec2MetadataEndpointProvider provider = Ec2MetadataEndpointProvider.builder() + .profileFile(content::build) + .profileName("testIPv4") + .build(); + assertThat(provider.resolveEndpointMode()).isEqualTo(IPV4); + assertThat(provider.resolveEndpoint(IPV4)).isEqualTo("http://42.42.42.42"); + } + + @Test + void endpointFromBuilder_withIpv6_shouldBesetCorrectly() { + ProfileFile.Builder content = ProfileFile.builder() + .type(ProfileFile.Type.CONFIGURATION) + .content(Paths.get("src/test/resources/profile-config/test-profiles.tst")); + Ec2MetadataEndpointProvider provider = Ec2MetadataEndpointProvider.builder() + .profileFile(content::build) + .profileName("testIPv6") + .build(); + assertThat(provider.resolveEndpointMode()).isEqualTo(IPV6); + assertThat(provider.resolveEndpoint(IPV6)).isEqualTo("[1234:ec2::456]"); + } + + +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/LargeAsyncRequestTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/LargeAsyncRequestTest.java new file mode 100644 index 000000000000..9f347361ab1a --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/LargeAsyncRequestTest.java @@ -0,0 +1,84 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.imds.TestConstants.AMI_ID_RESOURCE; +import static software.amazon.awssdk.imds.TestConstants.EC2_METADATA_TOKEN_TTL_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_RESOURCE_PATH; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.imds.Ec2MetadataAsyncClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; + +@WireMockTest +class LargeAsyncRequestTest { + private int port; + + @BeforeEach + public void init(WireMockRuntimeInfo wiremock) { + this.port = wiremock.getHttpPort(); + } + + @Test + void largeRequestTest() throws Exception { + + int size = 10 * 1024 * 1024; // 10MB + byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = (byte) (i % 128); + } + String ec2MetadataContent = new String(bytes, StandardCharsets.US_ASCII); + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody("some-token").withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody(ec2MetadataContent))); + + try (Ec2MetadataAsyncClient client = + Ec2MetadataAsyncClient.builder() + .endpoint(URI.create("http://localhost:" + port)) + .httpClient(NettyNioAsyncHttpClient.builder().readTimeout(Duration.ofSeconds(30)).build()) + .build()) { + CompletableFuture res = client.get(AMI_ID_RESOURCE); + Ec2MetadataResponse response = res.get(); + assertThat(response.asString()).hasSize(size); + assertThat(response.asString()).isEqualTo(ec2MetadataContent); + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(1), getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)) + .withHeader(TOKEN_HEADER, equalTo("some-token"))); + } + + } +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/MultipleAsyncRequestsTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/MultipleAsyncRequestsTest.java new file mode 100644 index 000000000000..d49f0b3e4d70 --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/MultipleAsyncRequestsTest.java @@ -0,0 +1,93 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.imds.TestConstants.AMI_ID_RESOURCE; +import static software.amazon.awssdk.imds.TestConstants.EC2_METADATA_TOKEN_TTL_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_HEADER; +import static software.amazon.awssdk.imds.TestConstants.TOKEN_RESOURCE_PATH; + +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.imds.Ec2MetadataAsyncClient; +import software.amazon.awssdk.imds.Ec2MetadataResponse; + +@WireMockTest +class MultipleAsyncRequestsTest { + + private int port; + + @BeforeEach + public void init(WireMockRuntimeInfo wiremock) { + this.port = wiremock.getHttpPort(); + } + + @Test + void multipleRequests() { + int totalRequests = 128; + String tokenValue = "some-token"; + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn( + aResponse().withBody(tokenValue).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600"))); + for (int i = 0; i < totalRequests; i++) { + ResponseDefinitionBuilder responseStub = aResponse().withStatus(200).withBody("response::" + i); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE + "/" + i)).willReturn(responseStub)); + } + Ec2MetadataAsyncClient client = Ec2MetadataAsyncClient.builder() + .endpoint(URI.create("http://localhost:" + this.port)) + .build(); + List> requests = Stream.iterate(0, x -> x + 1) + .map(i -> client.get(AMI_ID_RESOURCE + "/" + i)) + .limit(totalRequests) + .collect(Collectors.toList()); + CompletableFuture> responses = CompletableFuture + .allOf(requests.toArray(new CompletableFuture[0])) + .thenApply(unusedVoid -> requests.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList())); + + List resolvedResponses = responses.join(); + for (int i = 0; i < totalRequests; i++) { + Ec2MetadataResponse response = resolvedResponses.get(i); + assertThat(response.asString()).isEqualTo("response::" + i); + } + verify(exactly(1), putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + verify(exactly(totalRequests), getRequestedFor(urlPathMatching(AMI_ID_RESOURCE + "/" + "\\d+")) + .withHeader(TOKEN_HEADER, equalTo(tokenValue))); + + } +} diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/unmarshall/document/DocumentUnmarshallerTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/unmarshall/document/DocumentUnmarshallerTest.java new file mode 100644 index 000000000000..bf0c5818f606 --- /dev/null +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/unmarshall/document/DocumentUnmarshallerTest.java @@ -0,0 +1,89 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * 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://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. 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 software.amazon.awssdk.imds.internal.unmarshall.document; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.SdkNumber; +import software.amazon.awssdk.core.document.Document; +import software.amazon.awssdk.protocols.jsoncore.JsonNode; +import software.amazon.awssdk.protocols.jsoncore.internal.EmbeddedObjectJsonNode; + + +class DocumentUnmarshallerTest { + @Test + void testDocumentFromNumberNode() { + JsonNode node = JsonNode.parser().parse("100"); + assertThat(Document.fromNumber(SdkNumber.fromInteger(100)).asNumber().intValue()) + .isEqualTo(node.visit(new DocumentUnmarshaller()).asNumber().intValue()); + } + + @Test + void testDocumentFromBoolean() { + JsonNode node = JsonNode.parser().parse("true"); + assertThat(Document.fromBoolean(true)).isEqualTo(node.visit(new DocumentUnmarshaller())); + } + + @Test + void testDocumentFromString() { + JsonNode node = JsonNode.parser().parse("\"100.00\""); + assertThat(Document.fromString("100.00")).isEqualTo(node.visit(new DocumentUnmarshaller())); + } + + @Test + void testDocumentFromNull() { + JsonNode node = JsonNode.parser().parse("null"); + assertThat(Document.fromNull()).isEqualTo(node.visit(new DocumentUnmarshaller())); + } + + @Test + void testExceptionIsThrownFromEmbededObjectType() { + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> new EmbeddedObjectJsonNode(new Object()).visit(new DocumentUnmarshaller())); + } + + @Test + void testDocumentFromObjectNode(){ + JsonNode node = JsonNode.parser().parse("{\"firstKey\": \"firstValue\", \"secondKey\": \"secondValue\"}"); + + Document documentMap = node.visit(new DocumentUnmarshaller()); + Map expectedMap = new LinkedHashMap<>(); + expectedMap.put("firstKey", Document.fromString("firstValue")); + expectedMap.put("secondKey", Document.fromString("secondValue")); + Document expectedDocumentMap = Document.fromMap(expectedMap); + assertThat(documentMap).isEqualTo(expectedDocumentMap); + } + + @Test + void testDocumentFromArrayNode(){ + JsonNode node = JsonNode.parser().parse("[\"One\", 10, true, null]"); + List documentList = new ArrayList<>(); + documentList.add(Document.fromString("One")); + documentList.add(Document.fromNumber(SdkNumber.fromBigDecimal(BigDecimal.TEN))); + documentList.add(Document.fromBoolean(true)); + documentList.add(Document.fromNull()); + Document document = Document.fromList(documentList); + Document actualDocument = node.visit(new DocumentUnmarshaller()); + assertThat(actualDocument).isEqualTo(document); + } +} diff --git a/core/imds/src/test/resources/profile-config/test-profiles.tst b/core/imds/src/test/resources/profile-config/test-profiles.tst new file mode 100644 index 000000000000..9787e34511ff --- /dev/null +++ b/core/imds/src/test/resources/profile-config/test-profiles.tst @@ -0,0 +1,13 @@ +[default] +aws_secret_access_key = defaultAccessKey +aws_access_key_id = defaultSecretAccessKey +region = us-west-2 + + +[profile testIPv4] +ec2_metadata_service_endpoint_mode = IPv4 +ec2_metadata_service_endpoint = http://42.42.42.42 + +[profile testIPv6] +ec2_metadata_service_endpoint_mode = IPv6 +ec2_metadata_service_endpoint = [1234:ec2::456] diff --git a/core/pom.xml b/core/pom.xml index 5484741cbace..361e2af40336 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -44,6 +44,7 @@ protocols metrics-spi json-utils + imds diff --git a/pom.xml b/pom.xml index 304e78aa2580..a8608d278a75 100644 --- a/pom.xml +++ b/pom.xml @@ -558,6 +558,7 @@ url-connection-client cloudwatch-metric-publisher utils + imds dynamodb-enhanced diff --git a/test/tests-coverage-reporting/pom.xml b/test/tests-coverage-reporting/pom.xml index d230e3055fdd..53ef2634c3fc 100644 --- a/test/tests-coverage-reporting/pom.xml +++ b/test/tests-coverage-reporting/pom.xml @@ -221,6 +221,11 @@ software.amazon.awssdk ${awsjavasdk.version}-PREVIEW + + software.amazon.awssdk + imds + ${awsjavasdk.version} +