Skip to content

Commit 2ff0d30

Browse files
Kotlin AWS sdk (#424)
Co-authored-by: Sam <[email protected]>
1 parent 4a760fa commit 2ff0d30

27 files changed

+891
-27
lines changed

build.gradle.kts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
2+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
23

34
buildscript {
45
repositories {
@@ -18,7 +19,7 @@ plugins {
1819
id("java-library")
1920
id("maven-publish")
2021
id("signing")
21-
kotlin("jvm").version("1.6.21")
22+
alias(libs.plugins.kotlin.jvm)
2223
}
2324

2425
allprojects {
@@ -41,19 +42,21 @@ allprojects {
4142
}
4243
}
4344

44-
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
45-
kotlinOptions.jvmTarget = "1.8"
45+
kotlin {
46+
jvmToolchain(11)
47+
compilerOptions.jvmTarget = JvmTarget.JVM_1_8
4648
}
4749

48-
java {
49-
toolchain {
50-
languageVersion.set(JavaLanguageVersion.of(11))
51-
}
50+
tasks.compileJava {
51+
options.release = 8
5252
}
5353

54-
tasks.compileJava {
55-
targetCompatibility = "1.8"
56-
sourceCompatibility = "1.8"
54+
tasks.compileTestKotlin {
55+
compilerOptions.jvmTarget = JvmTarget.JVM_11
56+
}
57+
58+
tasks.compileTestJava {
59+
options.release = 11
5760
}
5861

5962
repositories {

hoplite-aws-kotlin/build.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
plugins {
2+
alias(libs.plugins.kotlin.serialization)
3+
}
4+
5+
dependencies {
6+
api(projects.hopliteCore)
7+
api(libs.aws.kotlin.secretsmanager)
8+
api(libs.aws.kotlin.ssm)
9+
api(libs.regions)
10+
implementation(libs.kotlinx.serialization.json)
11+
testApi(libs.kotest.extensions.testcontainers)
12+
testApi(libs.testcontainers.localstack)
13+
}
14+
15+
apply("../publish.gradle.kts")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.sksamuel.hoplite.aws.kotlin
2+
3+
import aws.sdk.kotlin.services.secretsmanager.SecretsManagerClient
4+
import com.sksamuel.hoplite.ConfigResult
5+
import com.sksamuel.hoplite.DecoderContext
6+
import com.sksamuel.hoplite.Node
7+
import com.sksamuel.hoplite.StringNode
8+
import com.sksamuel.hoplite.fp.flatMap
9+
import com.sksamuel.hoplite.resolver.context.ContextResolver
10+
import kotlinx.coroutines.runBlocking
11+
12+
abstract class AbstractAwsSecretsManagerContextResolver(
13+
private val report: Boolean = false,
14+
createClient: () -> SecretsManagerClient = { runBlocking { SecretsManagerClient.fromEnvironment() } }
15+
) : ContextResolver() {
16+
17+
// should stay lazy so still be added to config even when not used, eg locally
18+
private val client by lazy { createClient() }
19+
private val ops by lazy { AwsOps(client) }
20+
21+
override fun lookup(path: String, node: StringNode, root: Node, context: DecoderContext): ConfigResult<String?> {
22+
val (key, index) = ops.extractIndex(path)
23+
return ops.fetchSecret(key)
24+
.onSuccess { if (report) ops.report(context, it) }
25+
.flatMap { ops.parseSecret(it, index) }
26+
}
27+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.sksamuel.hoplite.aws.kotlin
2+
3+
import aws.sdk.kotlin.runtime.AwsServiceException
4+
import aws.sdk.kotlin.runtime.ClientException
5+
import aws.sdk.kotlin.services.secretsmanager.SecretsManagerClient
6+
import aws.sdk.kotlin.services.secretsmanager.getSecretValue
7+
import aws.sdk.kotlin.services.secretsmanager.model.DecryptionFailure
8+
import aws.sdk.kotlin.services.secretsmanager.model.GetSecretValueResponse
9+
import aws.sdk.kotlin.services.secretsmanager.model.InvalidParameterException
10+
import aws.sdk.kotlin.services.secretsmanager.model.LimitExceededException
11+
import aws.sdk.kotlin.services.secretsmanager.model.ResourceNotFoundException
12+
import com.sksamuel.hoplite.ConfigFailure
13+
import com.sksamuel.hoplite.ConfigResult
14+
import com.sksamuel.hoplite.DecoderContext
15+
import com.sksamuel.hoplite.fp.invalid
16+
import com.sksamuel.hoplite.fp.valid
17+
import kotlinx.coroutines.runBlocking
18+
import kotlinx.serialization.decodeFromString
19+
import kotlinx.serialization.json.Json
20+
21+
class AwsOps(private val client: SecretsManagerClient) {
22+
23+
companion object {
24+
const val ReportSection = "AWS Secrets Manager Lookups"
25+
26+
// check for index, so we can decode json stored in AWS
27+
val keyRegex = "(.+)\\[(.+)]".toRegex()
28+
}
29+
30+
fun report(context: DecoderContext, result: GetSecretValueResponse) {
31+
context.reporter.report(
32+
ReportSection,
33+
mapOf(
34+
"Name" to result.name,
35+
"Arn" to result.arn,
36+
"Created Date" to result.createdDate.toString(),
37+
"Version Id" to result.versionId
38+
)
39+
)
40+
}
41+
42+
fun extractIndex(path: String): Pair<String, String?> {
43+
val keyMatch = keyRegex.matchEntire(path)
44+
return if (keyMatch == null)
45+
Pair(path, null)
46+
else
47+
Pair(keyMatch.groupValues[1], keyMatch.groupValues[2])
48+
}
49+
50+
fun parseSecret(result: GetSecretValueResponse, index: String?): ConfigResult<String> {
51+
52+
val secret = result.secretString
53+
return if (secret.isNullOrBlank())
54+
ConfigFailure.PreprocessorWarning("Empty secret '${result.name}' in AWS SecretsManager").invalid()
55+
else {
56+
if (index == null) {
57+
secret.valid()
58+
} else {
59+
val map = runCatching { Json.Default.decodeFromString<Map<String, String>>(secret) }.getOrElse { emptyMap() }
60+
map[index]?.valid()
61+
?: ConfigFailure.ResolverFailure(
62+
"Index '$index' not present in AWS secret '${result.name}'. Present keys are ${map.keys.joinToString(",")}"
63+
).invalid()
64+
}
65+
}
66+
}
67+
68+
fun fetchSecret(key: String): ConfigResult<GetSecretValueResponse> {
69+
return try {
70+
runBlocking {
71+
client.getSecretValue {
72+
secretId = key
73+
}
74+
}.valid()
75+
} catch (e: ResourceNotFoundException) {
76+
ConfigFailure.PreprocessorWarning("Could not locate resource '$key' in AWS SecretsManager").invalid()
77+
} catch (e: DecryptionFailure) {
78+
ConfigFailure.PreprocessorWarning("Could not decrypt resource '$key' in AWS SecretsManager").invalid()
79+
} catch (e: LimitExceededException) {
80+
ConfigFailure.PreprocessorWarning("Could not load resource '$key' due to limits exceeded").invalid()
81+
} catch (e: InvalidParameterException) {
82+
ConfigFailure.PreprocessorWarning("Invalid parameter name '$key' in AWS SecretsManager").invalid()
83+
} catch (e: AwsServiceException) {
84+
ConfigFailure.PreprocessorFailure("Failed loading secret '$key' from AWS SecretsManager", e).invalid()
85+
} catch (e: ClientException) {
86+
ConfigFailure.PreprocessorFailure("Failed loading secret '$key' from AWS SecretsManager", e).invalid()
87+
} catch (e: Exception) {
88+
ConfigFailure.PreprocessorFailure("Failed loading secret '$key' from AWS SecretsManager", e).invalid()
89+
}
90+
}
91+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.sksamuel.hoplite.aws.kotlin
2+
3+
import aws.sdk.kotlin.services.secretsmanager.SecretsManagerClient
4+
import kotlinx.coroutines.runBlocking
5+
6+
/**
7+
* Replaces strings of the form ${{ aws-secrets-manager:path }} by looking up the path in AWS Secrets Manager.
8+
*
9+
* The [SecretsManagerClient] client is created from the [createClient] argument which uses the
10+
* standard [SecretsManagerClient.fromEnvironment()] by default.
11+
*/
12+
class AwsSecretsManagerContextResolver(
13+
report: Boolean = false,
14+
createClient: () -> SecretsManagerClient = { runBlocking { SecretsManagerClient.fromEnvironment() } }
15+
) : AbstractAwsSecretsManagerContextResolver(report, createClient) {
16+
override val contextKey: String = "aws-secrets-manager"
17+
override val default: Boolean = false
18+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.sksamuel.hoplite.aws.kotlin
2+
3+
import aws.sdk.kotlin.runtime.AwsServiceException
4+
import aws.sdk.kotlin.runtime.ClientException
5+
import aws.sdk.kotlin.services.secretsmanager.SecretsManagerClient
6+
import aws.sdk.kotlin.services.secretsmanager.getSecretValue
7+
import aws.sdk.kotlin.services.secretsmanager.model.DecryptionFailure
8+
import aws.sdk.kotlin.services.secretsmanager.model.InvalidParameterException
9+
import aws.sdk.kotlin.services.secretsmanager.model.LimitExceededException
10+
import aws.sdk.kotlin.services.secretsmanager.model.ResourceNotFoundException
11+
import com.sksamuel.hoplite.CommonMetadata
12+
import com.sksamuel.hoplite.ConfigFailure
13+
import com.sksamuel.hoplite.ConfigResult
14+
import com.sksamuel.hoplite.DecoderContext
15+
import com.sksamuel.hoplite.Node
16+
import com.sksamuel.hoplite.PrimitiveNode
17+
import com.sksamuel.hoplite.StringNode
18+
import com.sksamuel.hoplite.fp.invalid
19+
import com.sksamuel.hoplite.fp.valid
20+
import com.sksamuel.hoplite.preprocessor.TraversingPrimitivePreprocessor
21+
import com.sksamuel.hoplite.withMeta
22+
import kotlinx.coroutines.runBlocking
23+
import kotlinx.serialization.decodeFromString
24+
import kotlinx.serialization.json.Json
25+
26+
/**
27+
* @param report set to true to output a report on secrets used.
28+
* Requires the overall hoplite report to be enabled.
29+
*/
30+
class AwsSecretsManagerPreprocessor(
31+
private val report: Boolean = false,
32+
private val json: Json,
33+
createClient: () -> SecretsManagerClient = { runBlocking { SecretsManagerClient.fromEnvironment() } }
34+
) : TraversingPrimitivePreprocessor() {
35+
36+
constructor(
37+
report: Boolean = false,
38+
createClient: () -> SecretsManagerClient = { runBlocking { SecretsManagerClient.fromEnvironment() } }
39+
) : this(report, Json.Default, createClient)
40+
41+
private val client by lazy { createClient() }
42+
private val regex1 = "\\$\\{awssecret:(.+?)}".toRegex()
43+
private val regex2 = "secretsmanager://(.+?)".toRegex()
44+
private val regex3 = "awssm://(.+?)".toRegex()
45+
private val keyRegex = "(.+)\\[(.+)]".toRegex()
46+
47+
override fun handle(node: PrimitiveNode, context: DecoderContext): ConfigResult<Node> = when (node) {
48+
is StringNode -> {
49+
when (
50+
val match = regex1.matchEntire(node.value) ?: regex2.matchEntire(node.value) ?: regex3.matchEntire(node.value)
51+
) {
52+
null -> node.valid()
53+
else -> {
54+
val value = match.groupValues[1].trim()
55+
val keyMatch = keyRegex.matchEntire(value)
56+
val (key, index) = if (keyMatch == null) Pair(value, null) else
57+
Pair(keyMatch.groupValues[1], keyMatch.groupValues[2])
58+
fetchSecret(key, index, node, context)
59+
}
60+
}
61+
}
62+
else -> node.valid()
63+
}
64+
65+
private fun fetchSecret(key: String, index: String?, node: StringNode, context: DecoderContext): ConfigResult<Node> {
66+
return try {
67+
68+
val value = runBlocking {
69+
client.getSecretValue {
70+
secretId = key
71+
}
72+
}
73+
74+
if (report)
75+
context.reporter.report(
76+
"AWS Secrets Manager Lookups",
77+
mapOf(
78+
"Name" to value.name,
79+
"Arn" to value.arn,
80+
"Created Date" to value.createdDate.toString(),
81+
"Version Id" to value.versionId
82+
)
83+
)
84+
85+
val secret = value.secretString
86+
if (secret.isNullOrBlank())
87+
ConfigFailure.PreprocessorWarning("Empty secret '$key' in AWS SecretsManager").invalid()
88+
else {
89+
if (index == null) {
90+
node.copy(value = secret)
91+
.withMeta(CommonMetadata.Secret, true)
92+
.withMeta(CommonMetadata.UnprocessedValue, node.value)
93+
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key'")
94+
.valid()
95+
} else {
96+
val map = runCatching { json.decodeFromString<Map<String, String>>(secret) }.getOrElse { emptyMap() }
97+
val indexedValue = map[index]
98+
if (indexedValue == null)
99+
ConfigFailure.PreprocessorWarning(
100+
"Index '$index' not present in secret '$key'. Available keys are ${
101+
map.keys.joinToString(
102+
","
103+
)
104+
}"
105+
).invalid()
106+
else
107+
node.copy(value = indexedValue)
108+
.withMeta(CommonMetadata.Secret, true)
109+
.withMeta(CommonMetadata.UnprocessedValue, node.value)
110+
.withMeta(CommonMetadata.RemoteLookup, "AWS '$key[$index]'")
111+
.valid()
112+
}
113+
}
114+
} catch (e: ResourceNotFoundException) {
115+
ConfigFailure.PreprocessorWarning("Could not locate resource '$key' in AWS SecretsManager").invalid()
116+
} catch (e: DecryptionFailure) {
117+
ConfigFailure.PreprocessorWarning("Could not decrypt resource '$key' in AWS SecretsManager").invalid()
118+
} catch (e: LimitExceededException) {
119+
ConfigFailure.PreprocessorWarning("Could not load resource '$key' due to limits exceeded").invalid()
120+
} catch (e: InvalidParameterException) {
121+
ConfigFailure.PreprocessorWarning("Invalid parameter name '$key' in AWS SecretsManager").invalid()
122+
} catch (e: AwsServiceException) {
123+
ConfigFailure.PreprocessorFailure("Failed loading secret '$key' from AWS SecretsManager", e).invalid()
124+
} catch (e: ClientException) {
125+
ConfigFailure.PreprocessorFailure("Failed loading secret '$key' from AWS SecretsManager", e).invalid()
126+
} catch (e: Exception) {
127+
ConfigFailure.PreprocessorFailure("Failed loading secret '$key' from AWS SecretsManager", e).invalid()
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)