Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support map lookup in AWS preprocessors #341

Merged
merged 6 commits into from Aug 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion hoplite-aws/build.gradle.kts
@@ -1,11 +1,15 @@
plugins {
kotlin("plugin.serialization") version "1.6.21"
}

dependencies {
api(project(":hoplite-core"))
api(Libs.Aws.core)
api(Libs.Aws.ssm)
api(Libs.Aws.secrets)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
testApi(Libs.Kotest.testContainers)
testApi(Libs.TestContainers.localstack)

testApi(Libs.Logback.classic)
testApi(Libs.Slf4j.api)
}
Expand Down
Expand Up @@ -19,6 +19,8 @@ import com.sksamuel.hoplite.fp.invalid
import com.sksamuel.hoplite.fp.valid
import com.sksamuel.hoplite.preprocessor.TraversingPrimitivePreprocessor
import com.sksamuel.hoplite.withMeta
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

class AwsSecretsManagerPreprocessor(
private val createClient: () -> AWSSecretsManager = { AWSSecretsManagerClientBuilder.standard().build() }
Expand All @@ -28,31 +30,51 @@ class AwsSecretsManagerPreprocessor(
private val regex1 = "\\$\\{awssecret:(.+?)}".toRegex()
private val regex2 = "secretsmanager://(.+?)".toRegex()
private val regex3 = "awssm://(.+?)".toRegex()
private val keyRegex = "(.+)\\[(.+)]".toRegex()

override fun handle(node: PrimitiveNode): ConfigResult<Node> = when (node) {
is StringNode -> {
when (
val match = regex1.matchEntire(node.value) ?: regex2.matchEntire(node.value) ?: regex3.matchEntire(node.value)
) {
null -> node.valid()
else -> fetchSecret(match.groupValues[1].trim(), node)
else -> {
val value = match.groupValues[1].trim()
val keyMatch = keyRegex.matchEntire(value)
val (key, index) = if (keyMatch == null) Pair(value, null) else
Pair(keyMatch.groupValues[1], keyMatch.groupValues[2])
fetchSecret(key, index, node)
}
}
}
else -> node.valid()
}

private fun fetchSecret(key: String, node: StringNode): ConfigResult<Node> {
private fun fetchSecret(key: String, index: String?, node: StringNode): ConfigResult<Node> {
return try {
val req = GetSecretValueRequest().withSecretId(key)
val value = client.getSecretValue(req).secretString
if (value.isNullOrBlank())
val secret = client.getSecretValue(req).secretString
if (secret.isNullOrBlank())
ConfigFailure.PreprocessorWarning("Empty secret '$key' in AWS SecretsManager").invalid()
else {
val copied = node.copy(value = value)
.withMeta(CommonMetadata.Secret, true)
.withMeta(CommonMetadata.UnprocessedValue, node.value)
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key'")
copied.valid()
if (index == null) {
node.copy(value = secret)
.withMeta(CommonMetadata.Secret, true)
.withMeta(CommonMetadata.UnprocessedValue, node.value)
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key'")
.valid()
} else {
val map = runCatching { Json.Default.decodeFromString<Map<String, String>>(secret) }.getOrElse { emptyMap() }
val indexedValue = map[index]
if (indexedValue == null)
ConfigFailure.PreprocessorWarning("Index '$index' not present in secret '$key'. Available keys are ${map.keys.joinToString(",")}").invalid()
else
node.copy(value = indexedValue)
.withMeta(CommonMetadata.Secret, true)
.withMeta(CommonMetadata.UnprocessedValue, node.value)
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key[$index]'")
.valid()
}
}
} catch (e: ResourceNotFoundException) {
ConfigFailure.PreprocessorWarning("Could not locate resource '$key' in AWS SecretsManager").invalid()
Expand Down
Expand Up @@ -10,6 +10,7 @@ import com.sksamuel.hoplite.Pos
import com.sksamuel.hoplite.StringNode
import com.sksamuel.hoplite.decoder.DotPath
import com.sksamuel.hoplite.fp.Validated
import com.sksamuel.hoplite.parsers.PropsPropertySource
import com.sksamuel.hoplite.traverse
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.extensions.install
Expand All @@ -22,6 +23,7 @@ import io.kotest.matchers.string.shouldNotContain
import io.kotest.matchers.types.shouldBeInstanceOf
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName
import java.util.Properties

class AwsSecretsManagerPreprocessorTest : FunSpec() {

Expand All @@ -38,6 +40,7 @@ class AwsSecretsManagerPreprocessorTest : FunSpec() {
.build()

client.createSecret(CreateSecretRequest().withName("foo").withSecretString("secret!"))
client.createSecret(CreateSecretRequest().withName("bubble").withSecretString("""{"f": "1", "g": "2"}"""))

test("placeholder should be detected and used") {
ConfigLoaderBuilder.default()
Expand Down Expand Up @@ -109,6 +112,17 @@ class AwsSecretsManagerPreprocessorTest : FunSpec() {
.loadConfigOrThrow<ConfigHolder>("/multiple_secrets.props")
}.message.shouldContain("foo.bar").shouldContain("bar.baz")
}

test("should support index keys") {
val props = Properties()
props["a"] = "awssm://bubble[f]"
ConfigLoaderBuilder.default()
.addPreprocessor(AwsSecretsManagerPreprocessor { client })
.addPropertySource(PropsPropertySource(props))
.build()
.loadConfigOrThrow<ConfigHolder>()
.a shouldBe "1"
}
}

data class ConfigHolder(val a: String)
Expand Down
3 changes: 2 additions & 1 deletion hoplite-aws2/build.gradle.kts
@@ -1,11 +1,12 @@
plugins {
kotlin("jvm")
kotlin("plugin.serialization") version "1.6.21"
}

dependencies {
api(project(":hoplite-core"))
api(Libs.Aws2.regions)
api(Libs.Aws2.secretsmanager)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
}

apply("../publish.gradle.kts")
Expand Up @@ -10,6 +10,8 @@ import com.sksamuel.hoplite.fp.invalid
import com.sksamuel.hoplite.fp.valid
import com.sksamuel.hoplite.preprocessor.TraversingPrimitivePreprocessor
import com.sksamuel.hoplite.withMeta
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient
import software.amazon.awssdk.services.secretsmanager.model.DecryptionFailureException
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest
Expand All @@ -26,32 +28,53 @@ class AwsSecretsManagerPreprocessor(
private val regex1 = "\\$\\{awssecret:(.+?)}".toRegex()
private val regex2 = "secretsmanager://(.+?)".toRegex()
private val regex3 = "awssm://(.+?)".toRegex()
private val keyRegex = "(.+)\\[(.+)]".toRegex()

override fun handle(node: PrimitiveNode): ConfigResult<Node> = when (node) {
is StringNode -> {
when (
val match = regex1.matchEntire(node.value) ?: regex2.matchEntire(node.value) ?: regex3.matchEntire(node.value)
) {
null -> node.valid()
else -> fetchSecret(match.groupValues[1].trim(), node)
else -> {
val value = match.groupValues[1].trim()
val keyMatch = keyRegex.matchEntire(value)
val (key, index) = if (keyMatch == null) Pair(value, null) else
Pair(keyMatch.groupValues[1], keyMatch.groupValues[2])
fetchSecret(key, index, node)
}
}
}
else -> node.valid()
}

private fun fetchSecret(key: String, node: StringNode): ConfigResult<Node> {
private fun fetchSecret(key: String, index: String?, node: StringNode): ConfigResult<Node> {
return try {
val valueRequest = GetSecretValueRequest.builder().secretId(key).build()
val valueResponse = client.getSecretValue(valueRequest)
val value = valueResponse.secretString()
if (value.isNullOrBlank())
val value = client.getSecretValue(valueRequest)
val secret = value.secretString()
if (secret.isNullOrBlank())
ConfigFailure.PreprocessorWarning("Empty secret '$key' in AWS SecretsManager").invalid()
else
node.copy(value = value)
.withMeta(CommonMetadata.Secret, true)
.withMeta(CommonMetadata.UnprocessedValue, node.value)
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key'")
.valid()
else {
if (index == null) {
node.copy(value = secret)
.withMeta(CommonMetadata.Secret, true)
.withMeta(CommonMetadata.UnprocessedValue, node.value)
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key'")
.valid()
} else {
val map = runCatching { Json.Default.decodeFromString<Map<String, String>>(secret) }.getOrElse { emptyMap() }
val indexedValue = map[index]
if (indexedValue == null)
ConfigFailure.PreprocessorWarning("Index '$index' not present in secret '$key'. Available keys are ${map.keys.joinToString(",")}").invalid()
else
node.copy(value = indexedValue)
.withMeta(CommonMetadata.Secret, true)
.withMeta(CommonMetadata.UnprocessedValue, node.value)
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key[$index]'")
.valid()
}
}
} catch (e: ResourceNotFoundException) {
ConfigFailure.PreprocessorWarning("Could not locate resource '$key' in AWS SecretsManager").invalid()
} catch (e: DecryptionFailureException) {
Expand Down
3 changes: 3 additions & 0 deletions settings.gradle.kts
Expand Up @@ -17,6 +17,9 @@ plugins {

refreshVersions {
enableBuildSrcLibs()
rejectVersionIf {
candidate.stabilityLevel != de.fayard.refreshVersions.core.StabilityLevel.Stable
}
}

include("hoplite-core")
Expand Down