diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go
index c98a9553e6..e906f70a12 100644
--- a/go-runtime/compile/build.go
+++ b/go-runtime/compile/build.go
@@ -10,10 +10,11 @@ import (
"reflect"
"strings"
- "github.com/TBD54566975/scaffolder"
"github.com/iancoleman/strcase"
"google.golang.org/protobuf/proto"
+ "github.com/TBD54566975/scaffolder"
+
"github.com/TBD54566975/ftl/backend/common/exec"
"github.com/TBD54566975/ftl/backend/common/log"
"github.com/TBD54566975/ftl/backend/common/moduleconfig"
@@ -88,7 +89,7 @@ func Build(ctx context.Context, moduleDir string, sch *schema.Schema) error {
if err := exec.Command(ctx, log.Debug, mainDir, "go", "mod", "tidy").Run(); err != nil {
return fmt.Errorf("failed to tidy go.mod: %w", err)
}
- return exec.Command(ctx, log.Debug, mainDir, "go", "build", "-o", "../../main", ".").Run()
+ return exec.Command(ctx, log.Info, mainDir, "go", "build", "-o", "../../main", ".").Run()
}
var scaffoldFuncs = scaffolder.FuncMap{
diff --git a/go-runtime/sdk/config.go b/go-runtime/sdk/config.go
new file mode 100644
index 0000000000..b75c8cf26a
--- /dev/null
+++ b/go-runtime/sdk/config.go
@@ -0,0 +1,62 @@
+package sdk
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+)
+
+// ConfigType is a type that can be used as a configuration value.
+//
+// Supported types are currently limited, but will eventually be extended to
+// allow any type that FTL supports, including structs.
+type ConfigType interface {
+ string | int | float64 | bool |
+ []string | []int | []float64 | []bool |
+ map[string]string | map[string]int | map[string]float64 | map[string]bool
+}
+
+// Config declares a typed configuration key for the current module.
+func Config[Type ConfigType](name string) ConfigValue[Type] {
+ module := callerModule()
+ return ConfigValue[Type]{module, name}
+}
+
+// ConfigValue is a typed configuration key for the current module.
+type ConfigValue[Type ConfigType] struct {
+ module string
+ name string
+}
+
+// Get returns the value of the configuration key from FTL.
+func (c *ConfigValue[Type]) Get() (out Type) {
+ value, ok := os.LookupEnv(fmt.Sprintf("FTL_CONFIG_%s_%s", strings.ToUpper(c.module), strings.ToUpper(c.name)))
+ if !ok {
+ return out
+ }
+ if err := json.Unmarshal([]byte(value), &out); err != nil {
+ panic(fmt.Errorf("failed to parse config value %s: %w", value, err))
+ }
+ return
+}
+
+func callerModule() string {
+ pc, _, _, ok := runtime.Caller(2)
+ if !ok {
+ panic("failed to get caller")
+ }
+ details := runtime.FuncForPC(pc)
+ if details == nil {
+ panic("failed to get caller")
+ }
+ module := details.Name()
+ if strings.HasPrefix(module, "github.com/TBD54566975/ftl/go-runtime/sdk") {
+ return "testing"
+ }
+ if !strings.HasPrefix(module, "ftl/") {
+ panic(fmt.Sprintf("must be called from an FTL module not %s", module))
+ }
+ return strings.Split(strings.Split(module, "/")[1], ".")[0]
+}
diff --git a/go-runtime/sdk/config_test.go b/go-runtime/sdk/config_test.go
new file mode 100644
index 0000000000..08140899d3
--- /dev/null
+++ b/go-runtime/sdk/config_test.go
@@ -0,0 +1,13 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/alecthomas/assert/v2"
+)
+
+func TestConfig(t *testing.T) {
+ t.Setenv("FTL_CONFIG_TESTING_TEST", `["one", "two", "three"]`)
+ config := Config[[]string]("test")
+ assert.Equal(t, []string{"one", "two", "three"}, config.Get())
+}
diff --git a/go-runtime/sdk/secrets.go b/go-runtime/sdk/secrets.go
new file mode 100644
index 0000000000..c7850d69a5
--- /dev/null
+++ b/go-runtime/sdk/secrets.go
@@ -0,0 +1,42 @@
+package sdk
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// SecretType is a type that can be used as a secret value.
+//
+// Supported types are currently limited, but will eventually be extended to
+// allow any type that FTL supports, including structs.
+type SecretType interface {
+ string | int | float64 | bool |
+ []string | []int | []float64 | []bool |
+ map[string]string | map[string]int | map[string]float64 | map[string]bool
+}
+
+// Secret declares a typed secret for the current module.
+func Secret[Type SecretType](name string) SecretValue[Type] {
+ module := callerModule()
+ return SecretValue[Type]{module, name}
+}
+
+// SecretValue is a typed secret for the current module.
+type SecretValue[Type SecretType] struct {
+ module string
+ name string
+}
+
+// Get returns the value of the secret from FTL.
+func (c *SecretValue[Type]) Get() (out Type) {
+ value, ok := os.LookupEnv(fmt.Sprintf("FTL_SECRET_%s_%s", strings.ToUpper(c.module), strings.ToUpper(c.name)))
+ if !ok {
+ return out
+ }
+ if err := json.Unmarshal([]byte(value), &out); err != nil {
+ panic(fmt.Errorf("failed to parse secret %s.%s: %w", c.module, c.name, err))
+ }
+ return
+}
diff --git a/kotlin-runtime/ftl-runtime/pom.xml b/kotlin-runtime/ftl-runtime/pom.xml
index 6970e10ed8..53ded133cf 100644
--- a/kotlin-runtime/ftl-runtime/pom.xml
+++ b/kotlin-runtime/ftl-runtime/pom.xml
@@ -109,6 +109,13 @@
test
+
+ org.junit-pioneer
+ junit-pioneer
+ 2.1.0
+ test
+
+
diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/config/Config.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/config/Config.kt
new file mode 100644
index 0000000000..5aa90467e9
--- /dev/null
+++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/config/Config.kt
@@ -0,0 +1,21 @@
+package xyz.block.ftl.config
+
+import xyz.block.ftl.serializer.makeGson
+
+class Secret(val name: String) {
+ val _module: String
+ val _gson = makeGson()
+
+ init {
+ val caller = Thread.currentThread().stackTrace[2].className
+ require(caller.startsWith("ftl.") || caller.startsWith("xyz.block.ftl.config.")) { "Config must be defined in an FTL module not $caller" }
+ val parts = caller.split(".")
+ _module = parts[parts.size - 2]
+ }
+
+ inline fun get(): T {
+ val key = "FTL_CONFIG_${_module.uppercase()}_${name.uppercase()}"
+ val value = System.getenv(key) ?: throw Exception("Config key ${_module}.${name} not found")
+ return _gson.fromJson(value, T::class.java)
+ }
+}
diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/config/ConfigTest.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/config/ConfigTest.kt
new file mode 100644
index 0000000000..2faeb242ef
--- /dev/null
+++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/config/ConfigTest.kt
@@ -0,0 +1,14 @@
+package xyz.block.ftl.config
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junitpioneer.jupiter.SetEnvironmentVariable
+
+class ConfigTest {
+ @Test
+ @SetEnvironmentVariable(key = "FTL_SECRET_SECRETS_TEST", value = "testingtesting")
+ fun testSecret() {
+ val secret = Secret("test")
+ assertEquals("testingtesting", secret.get())
+ }
+}
diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/secrets/Secrets.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/secrets/Secrets.kt
index 1e6f4e0a1b..bfe1baf244 100644
--- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/secrets/Secrets.kt
+++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/secrets/Secrets.kt
@@ -1,17 +1,21 @@
package xyz.block.ftl.secrets
-object Secrets {
- private const val FTL_SECRETS_ENV_VAR_PREFIX = "FTL_SECRET_"
+import xyz.block.ftl.serializer.makeGson
- fun get(name: String): String {
- if (!name.startsWith(FTL_SECRETS_ENV_VAR_PREFIX)) {
- throw Exception("Invalid secret name; must start with $FTL_SECRETS_ENV_VAR_PREFIX")
- }
+class Secret(val name: String) {
+ val module: String
+ val gson = makeGson()
- return try {
- System.getenv(name)
- } catch (e: Exception) {
- throw Exception("Secret $name not found")
- }
+ init {
+ val caller = Thread.currentThread().getStackTrace()[2].className
+ require(caller.startsWith("ftl.") || caller.startsWith("xyz.block.ftl.secrets.")) { "Secrets must be defined in an FTL module not ${caller}" }
+ val parts = caller.split(".")
+ module = parts[parts.size - 2]
+ }
+
+ inline fun get(): T {
+ val key = "FTL_SECRET_${module.uppercase()}_${name.uppercase()}"
+ val value = System.getenv(key) ?: throw Exception("Secret ${module}.${name} not found")
+ return gson.fromJson(value, T::class.java)
}
}
diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/secrets/SecretTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/secrets/SecretTest.kt
new file mode 100644
index 0000000000..ba3a0fd440
--- /dev/null
+++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/secrets/SecretTest.kt
@@ -0,0 +1,14 @@
+package xyz.block.ftl.secrets
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junitpioneer.jupiter.SetEnvironmentVariable
+
+class SecretTest {
+ @Test
+ @SetEnvironmentVariable(key = "FTL_SECRET_SECRETS_TEST", value = "testingtesting")
+ fun testSecret() {
+ val secret = Secret("test")
+ assertEquals("testingtesting", secret.get())
+ }
+}
diff --git a/pom.xml b/pom.xml
index 964db394dd..576444a43c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,6 +1,6 @@
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
@@ -195,6 +195,10 @@
Test*
*Test
+
+ --add-opens java.base/java.util=ALL-UNNAMED
+ --add-opens java.base/java.lang=ALL-UNNAMED
+