diff --git a/README.md b/README.md index ba7796f..c66c453 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,12 @@ You can get the latest release from Maven: com.amazonaws.secretsmanager aws-secretsmanager-caching-java - 2.2.0 + x.y.z ``` +Replace `x.y.z` with the latest version, which you can find on [Maven Central](https://central.sonatype.com/artifact/com.amazonaws.secretsmanager/aws-secretsmanager-caching-java). + Don't forget to enable the download of snapshot jars from Maven: ```xml diff --git a/pom.xml b/pom.xml index f7dacf3..fae4c75 100644 --- a/pom.xml +++ b/pom.xml @@ -71,6 +71,15 @@ + + + + src/main/resources + true + + org.apache.maven.plugins diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java index ad79122..4495ec1 100644 --- a/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java +++ b/src/main/java/com/amazonaws/secretsmanager/caching/SecretCache.java @@ -68,23 +68,49 @@ public SecretCache() { * using the * provided builder. * + *

+ * Any UserAgent suffix already configured on the builder is preserved: the + * caching client identifier ({@code AwsSecretCache/}) is appended to + * it rather than overwriting it. + * * @param builder The builder to use for creating the AWS Secrets Manager * client. */ @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Delegates to constructor that validates before field initialization") public SecretCache(SecretsManagerClientBuilder builder) { - this(new SecretCacheConfiguration().withClient(builder - .overrideConfiguration( - builder.overrideConfiguration().toBuilder() - .putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX, VersionInfo.USER_AGENT) - .build()) - .build())); + this(new SecretCacheConfiguration().withClient(buildClientWithUserAgent(builder))); + } + + /** + * Builds a client from the provided builder, appending the caching client + * UserAgent identifier while preserving any suffix the caller already set. + * + * @param builder The caller-provided builder. + * @return The built AWS Secrets Manager client. + */ + private static SecretsManagerClient buildClientWithUserAgent(SecretsManagerClientBuilder builder) { + ClientOverrideConfiguration existingOverride = builder.overrideConfiguration(); + String callerSuffix = existingOverride + .advancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX).orElse(null); + ClientOverrideConfiguration mergedOverride = existingOverride.toBuilder() + .putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX, + VersionInfo.userAgentSuffix(callerSuffix)) + .build(); + return builder.overrideConfiguration(mergedOverride).build(); } /** * Constructs a new secret cache using the provided AWS Secrets Manager client. * + *

+ * Note: a pre-built {@link SecretsManagerClient} is immutable, so the caching + * client UserAgent identifier ({@code AwsSecretCache/}) cannot be + * appended to it here. Callers who want the caching identifier reported in + * service logs should instead pass a {@link SecretsManagerClientBuilder} (via + * {@link #SecretCache(SecretsManagerClientBuilder)}), which preserves any + * existing suffix and appends the caching identifier. + * * @param client The AWS Secrets Manager client to use for requesting secret * values. */ @@ -117,7 +143,8 @@ public SecretCache(SecretCacheConfiguration config) { this.cache = new LRUCache(config.getMaxCacheSize()); this.config = config; ClientOverrideConfiguration defaultOverride = ClientOverrideConfiguration.builder() - .putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX, VersionInfo.USER_AGENT).build(); + .putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX, + VersionInfo.userAgentSuffix(null)).build(); if (config.getClient() != null) { this.client = config.getClient(); diff --git a/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java b/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java index 116f01b..1312350 100644 --- a/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java +++ b/src/main/java/com/amazonaws/secretsmanager/caching/cache/internal/VersionInfo.java @@ -13,21 +13,107 @@ package com.amazonaws.secretsmanager.caching.cache.internal; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + /** * This class specifies the versioning system for the AWS SecretsManager caching * client. + * + *

+ * The library version is defined in a single place ({@code pom.xml}) and made + * available at runtime through the {@code version.properties} classpath resource, + * whose {@code ${project.version}} placeholder is substituted at build time via + * Maven resource filtering. This avoids the version drift that occurs when the + * version has to be maintained in more than one place. */ -public class VersionInfo { +public final class VersionInfo { + + /** Placeholder returned when the version cannot be resolved at runtime. */ + public static final String UNKNOWN_VERSION = "unknown"; + + /** Prefix identifying this caching client in the SDK UserAgent header. */ + public static final String USER_AGENT_PREFIX = "AwsSecretCache/"; + + /** Name of the filtered classpath resource holding the version. */ + static final String VERSION_RESOURCE = "version.properties"; + + /** Properties key under which the version is stored. */ + static final String VERSION_KEY = "version"; + /** - * Library version number + * Library version number, resolved at runtime from {@code pom.xml} via the + * filtered {@code version.properties} resource. Falls back to + * {@link #UNKNOWN_VERSION} when the resource is unavailable. */ - public static final String RELEASE_VERSION = "2.1.0"; + public static final String RELEASE_VERSION = resolveVersion(); /** * User agent for AWS Secrets Manager API calls. */ - public static final String USER_AGENT = "AwsSecretCache/" + RELEASE_VERSION; + public static final String USER_AGENT = USER_AGENT_PREFIX + RELEASE_VERSION; private VersionInfo() { } -} \ No newline at end of file + + /** + * Resolves the library version from the filtered {@code version.properties} + * classpath resource. Never throws; returns {@link #UNKNOWN_VERSION} if the + * resource is missing or unreadable, so that a failure to resolve the version + * can never break secret retrieval. + * + * @return the resolved version, or {@link #UNKNOWN_VERSION}. + */ + private static String resolveVersion() { + return readVersion(VersionInfo.class.getResourceAsStream(VERSION_RESOURCE)); + } + + /** + * Parses the {@code version} property from the given stream, closing the + * stream before returning. + * + * @param in the properties stream (may be {@code null}); this method takes + * ownership and closes it. + * @return the version value, or {@link #UNKNOWN_VERSION} when the stream is + * {@code null}, unreadable, missing the key, blank, or still contains + * an unsubstituted Maven placeholder. + */ + public static String readVersion(final InputStream in) { + if (in == null) { + return UNKNOWN_VERSION; + } + try (InputStream stream = in) { + Properties properties = new Properties(); + properties.load(stream); + String version = properties.getProperty(VERSION_KEY); + if (version == null) { + return UNKNOWN_VERSION; + } + version = version.trim(); + if (version.isEmpty() || version.contains("${")) { + return UNKNOWN_VERSION; + } + return version; + } catch (IOException e) { + return UNKNOWN_VERSION; + } + } + + /** + * Builds the UserAgent suffix for the caching client, preserving any suffix a + * caller has already configured. When a caller suffix is present, the caching + * identifier is appended to it ({@code " AwsSecretCache/"}); + * otherwise the caching identifier is returned on its own. + * + * @param callerSuffix the caller-provided UserAgent suffix (may be {@code null} + * or blank). + * @return the combined UserAgent suffix. + */ + public static String userAgentSuffix(final String callerSuffix) { + if (callerSuffix == null || callerSuffix.trim().isEmpty()) { + return USER_AGENT; + } + return callerSuffix + " " + USER_AGENT; + } +} diff --git a/src/main/resources/com/amazonaws/secretsmanager/caching/cache/internal/version.properties b/src/main/resources/com/amazonaws/secretsmanager/caching/cache/internal/version.properties new file mode 100644 index 0000000..defbd48 --- /dev/null +++ b/src/main/resources/com/amazonaws/secretsmanager/caching/cache/internal/version.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java b/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java index 3e96373..e3ee640 100644 --- a/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java +++ b/src/test/java/com/amazonaws/secretsmanager/caching/SecretCacheTest.java @@ -24,6 +24,7 @@ import org.mockito.ArgumentMatcher; import org.mockito.ArgumentMatchers; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; @@ -32,6 +33,8 @@ import org.testng.annotations.Test; import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption; import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; import software.amazon.awssdk.services.secretsmanager.SecretsManagerClientBuilder; import software.amazon.awssdk.services.secretsmanager.model.DescribeSecretRequest; @@ -39,6 +42,8 @@ import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; +import com.amazonaws.secretsmanager.caching.cache.internal.VersionInfo; + /** * SecretCacheTest. */ @@ -526,4 +531,41 @@ public void testSecretCacheWithPQTLSEnabledNoClient() { } } } + + @Test + public void builderPathPreservesAndAppendsUserAgent() { + // A caller who already configured their own UserAgent suffix on the builder. + SecretsManagerClientBuilder builder = Mockito.mock(SecretsManagerClientBuilder.class); + ClientOverrideConfiguration existing = ClientOverrideConfiguration.builder() + .putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX, "MyApp/9.9").build(); + Mockito.when(builder.overrideConfiguration()).thenReturn(existing); + Mockito.when(builder.overrideConfiguration(Mockito.any(ClientOverrideConfiguration.class))).thenReturn(builder); + Mockito.when(builder.build()).thenReturn(asm); + + new SecretCache(builder).close(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ClientOverrideConfiguration.class); + Mockito.verify(builder).overrideConfiguration(captor.capture()); + String suffix = captor.getValue().advancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX).orElse(null); + + Assert.assertEquals(suffix, "MyApp/9.9 " + VersionInfo.USER_AGENT); + } + + @Test + public void builderPathSetsOursWhenNoCallerSuffix() { + // A caller who did not configure any UserAgent suffix. + SecretsManagerClientBuilder builder = Mockito.mock(SecretsManagerClientBuilder.class); + ClientOverrideConfiguration existing = ClientOverrideConfiguration.builder().build(); + Mockito.when(builder.overrideConfiguration()).thenReturn(existing); + Mockito.when(builder.overrideConfiguration(Mockito.any(ClientOverrideConfiguration.class))).thenReturn(builder); + Mockito.when(builder.build()).thenReturn(asm); + + new SecretCache(builder).close(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ClientOverrideConfiguration.class); + Mockito.verify(builder).overrideConfiguration(captor.capture()); + String suffix = captor.getValue().advancedOption(SdkAdvancedClientOption.USER_AGENT_SUFFIX).orElse(null); + + Assert.assertEquals(suffix, VersionInfo.USER_AGENT); + } } diff --git a/src/test/java/com/amazonaws/secretsmanager/caching/internal/VersionInfoTest.java b/src/test/java/com/amazonaws/secretsmanager/caching/internal/VersionInfoTest.java index d28b555..73166ce 100644 --- a/src/test/java/com/amazonaws/secretsmanager/caching/internal/VersionInfoTest.java +++ b/src/test/java/com/amazonaws/secretsmanager/caching/internal/VersionInfoTest.java @@ -1,5 +1,9 @@ package com.amazonaws.secretsmanager.caching.internal; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.regex.Pattern; import org.testng.Assert; @@ -8,6 +12,7 @@ import com.amazonaws.secretsmanager.caching.cache.internal.VersionInfo; public class VersionInfoTest { + @Test public void versionInfoIsValid() { String ua = VersionInfo.USER_AGENT; @@ -15,4 +20,72 @@ public void versionInfoIsValid() { Assert.assertTrue(p.matcher(ua).matches(), "User agent " + ua + " is not valid"); } + + @Test + public void releaseVersionResolvesToSemver() { + Pattern p = Pattern.compile("\\d+\\.\\d+\\.\\d+"); + + Assert.assertTrue(p.matcher(VersionInfo.RELEASE_VERSION).matches(), + "RELEASE_VERSION should resolve to a semver but was " + VersionInfo.RELEASE_VERSION); + } + + @Test + public void readVersionParsesValidStream() { + InputStream in = new ByteArrayInputStream("version=1.2.3".getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals(VersionInfo.readVersion(in), "1.2.3"); + } + + @Test + public void readVersionFallsBackOnNullStream() { + Assert.assertEquals(VersionInfo.readVersion(null), VersionInfo.UNKNOWN_VERSION); + } + + @Test + public void readVersionFallsBackWhenKeyMissing() { + InputStream in = new ByteArrayInputStream("foo=bar".getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals(VersionInfo.readVersion(in), VersionInfo.UNKNOWN_VERSION); + } + + @Test + public void readVersionFallsBackOnUnfilteredPlaceholder() { + InputStream in = new ByteArrayInputStream("version=${project.version}".getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals(VersionInfo.readVersion(in), VersionInfo.UNKNOWN_VERSION); + } + + @Test + public void readVersionFallsBackOnEmptyValue() { + InputStream in = new ByteArrayInputStream("version=".getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals(VersionInfo.readVersion(in), VersionInfo.UNKNOWN_VERSION); + } + + @Test + public void readVersionFallsBackOnIoException() { + InputStream in = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("simulated read failure"); + } + }; + + Assert.assertEquals(VersionInfo.readVersion(in), VersionInfo.UNKNOWN_VERSION); + } + + @Test + public void userAgentSuffixAppendsCallerSuffix() { + Assert.assertEquals(VersionInfo.userAgentSuffix("MyApp/9.9"), "MyApp/9.9 " + VersionInfo.USER_AGENT); + } + + @Test + public void userAgentSuffixWithNullCaller() { + Assert.assertEquals(VersionInfo.userAgentSuffix(null), VersionInfo.USER_AGENT); + } + + @Test + public void userAgentSuffixWithBlankCaller() { + Assert.assertEquals(VersionInfo.userAgentSuffix(" "), VersionInfo.USER_AGENT); + } }