Skip to content

Commit 187c90f

Browse files
authored
feat: add secret management with template placeholders (#93)
1 parent 4cf53e5 commit 187c90f

4 files changed

Lines changed: 133 additions & 2 deletions

File tree

cli/src/main/scala/com/eff3ct/teckel/io/Parser.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ package com.eff3ct.teckel.io
2727
import cats.effect.Async
2828
import com.eff3ct.teckel.api.core.Run
2929
import com.eff3ct.teckel.semantic.core.EvalContext
30-
import com.eff3ct.teckel.serializer.{ConfigMerger, VariableResolver}
30+
import com.eff3ct.teckel.serializer.{ConfigMerger, SecretResolver, VariableResolver}
3131
import fs2.io.file.{Files, Path}
3232
import fs2.io.stdinUtf8
3333

@@ -54,13 +54,14 @@ object Parser {
5454
case Right(content) => content
5555
case Left(err) => throw new RuntimeException(s"Failed to merge configs: ${err.message}")
5656
}
57-
resolved = VariableResolver.resolve(merged, variables)
57+
resolved = SecretResolver.resolve(VariableResolver.resolve(merged, variables))
5858
result <- fs2.Stream.eval(Run[F].run[O](resolved))
5959
} yield result
6060
case None =>
6161
Files[F]
6262
.readUtf8(Path(file))
6363
.map(content => VariableResolver.resolve(content, variables))
64+
.map(content => SecretResolver.resolve(content))
6465
.evalMap(Run[F].run[O])
6566
}
6667

@@ -69,6 +70,7 @@ object Parser {
6970
): fs2.Stream[F, O] =
7071
stdinUtf8(1024)
7172
.map(content => VariableResolver.resolve(content, variables))
73+
.map(content => SecretResolver.resolve(content))
7274
.evalMap(Run[F].run[O])
7375

7476
}

docs/etl/secrets.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
secrets:
2+
keys:
3+
db_password:
4+
scope: my-keyvault
5+
key: database-password
6+
7+
input:
8+
- name: dbSource
9+
format: jdbc
10+
options:
11+
url: 'jdbc:postgresql://host:5432/db'
12+
user: admin
13+
password: '{{secrets.db_password}}'
14+
15+
output:
16+
- name: dbSource
17+
format: parquet
18+
mode: overwrite
19+
path: 'data/parquet/db_export'
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Rafael Fernandez
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.eff3ct.teckel.serializer
26+
27+
import io.circe.Json
28+
import io.circe.yaml.parser
29+
30+
object SecretResolver {
31+
32+
private val secretPattern = """\{\{secrets\.([^}]+)\}\}""".r
33+
34+
case class SecretKey(alias: String, scope: String, key: String)
35+
36+
def resolve(content: String, provider: SecretsProvider = SecretsProvider.default): String = {
37+
// First, try to parse the YAML to extract the secrets.keys section
38+
val secretKeys: Map[String, SecretKey] = parser
39+
.parse(content)
40+
.toOption
41+
.flatMap(_.hcursor.downField("secrets").downField("keys").as[Map[String, Json]].toOption)
42+
.map(_.flatMap { case (alias, json) =>
43+
for {
44+
scope <- json.hcursor.downField("scope").as[String].toOption
45+
key <- json.hcursor.downField("key").as[String].toOption
46+
} yield alias -> SecretKey(alias, scope, key)
47+
})
48+
.getOrElse(Map.empty)
49+
50+
// Replace all {{secrets.alias}} placeholders
51+
secretPattern.replaceAllIn(
52+
content,
53+
{ m =>
54+
val alias = m.group(1)
55+
val value = secretKeys.get(alias) match {
56+
case Some(sk) => provider.get(sk.scope, sk.key)
57+
case None => provider.get("", alias)
58+
}
59+
value match {
60+
case Some(v) => java.util.regex.Matcher.quoteReplacement(v)
61+
case None =>
62+
throw new IllegalArgumentException(
63+
s"Secret '{{secrets.$alias}}' could not be resolved. Set environment variable SECRETS__${alias.toUpperCase
64+
.replace("-", "_")} or configure a secrets provider."
65+
)
66+
}
67+
}
68+
)
69+
}
70+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Rafael Fernandez
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.eff3ct.teckel.serializer
26+
27+
trait SecretsProvider {
28+
def get(scope: String, key: String): Option[String]
29+
}
30+
31+
object SecretsProvider {
32+
33+
object Environment extends SecretsProvider {
34+
override def get(scope: String, key: String): Option[String] =
35+
Option(System.getenv(s"SECRETS__${key.toUpperCase.replace("-", "_").replace(".", "_")}"))
36+
.orElse(Option(System.getenv(key.toUpperCase.replace("-", "_").replace(".", "_"))))
37+
}
38+
39+
val default: SecretsProvider = Environment
40+
}

0 commit comments

Comments
 (0)