Skip to content

Commit ded6fc2

Browse files
committed
feat: Make EnvVarResolver extendable, add more config options (#352)
1 parent 15076cc commit ded6fc2

5 files changed

Lines changed: 125 additions & 115 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ helm install sql-example -f <your-helm-values>.yaml <your-helm-chart>
143143

144144
### Environment Variable Substitution
145145

146-
Flink SQL Runner automatically substitutes environment variables in your configuration files, SQL scripts, and compiled plans for secrets and environment specific configuration. Environment variables must be of the form `${ENV_VARIABLE}` and inside of strings.
146+
Flink SQL Runner automatically substitutes environment variables in your configuration files, SQL scripts, and compiled plans for secrets and environment specific configuration.
147+
Environment variables must be of the form `${ENV_VARIABLE}` and inside of strings.
148+
They also support bash-style defaults using `${ENV_VARIABLE:-default}` or `${ENV_VARIABLE:=default}`.
147149

148150
For example, `${DATA_PATH}` is an environment variable inside the connector configuration of a table that is substituted at runtime:
149151
```sql

env-utils/src/main/java/com/datasqrl/flinkrunner/utils/EnvUtils.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,20 @@ public final class EnvUtils {
3838
* @return an immutable map containing all environment variables with defaults applied
3939
*/
4040
public static Map<String, String> getEnvWithDeploymentDefaults() {
41-
var env = new HashMap<>(System.getenv());
41+
return addDeploymentDefaults(System.getenv());
42+
}
43+
44+
/**
45+
* Returns a copy of the supplied environment variables with deployment-specific defaults added.
46+
*
47+
* <p>Deployment defaults are only added when the supplied map does not already contain those
48+
* keys. Existing values are preserved.
49+
*
50+
* @param envVars environment variables to augment with deployment defaults
51+
* @return an immutable map containing the supplied variables plus any missing deployment defaults
52+
*/
53+
public static Map<String, String> addDeploymentDefaults(Map<String, String> envVars) {
54+
var env = new HashMap<>(envVars);
4255
getDeploymentDefaults().forEach(env::putIfAbsent);
4356

4457
return Map.copyOf(env);

env-utils/src/main/java/com/datasqrl/flinkrunner/utils/EnvVarResolver.java

Lines changed: 15 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
import com.fasterxml.jackson.databind.ObjectMapper;
2222
import com.fasterxml.jackson.databind.module.SimpleModule;
2323
import java.io.IOException;
24-
import java.util.HashMap;
2524
import java.util.HashSet;
2625
import java.util.Map;
26+
import java.util.Set;
2727
import java.util.regex.Matcher;
2828
import java.util.regex.Pattern;
29+
import lombok.Builder;
30+
import lombok.experimental.SuperBuilder;
2931
import lombok.extern.slf4j.Slf4j;
3032

3133
/**
@@ -36,106 +38,15 @@
3638
* non-strict mode.
3739
*/
3840
@Slf4j
41+
@SuperBuilder
3942
public class EnvVarResolver {
4043

41-
private static final Pattern ENVIRONMENT_VARIABLE_PATTERN = Pattern.compile("\\$\\{(.*?)\\}");
44+
private static final Pattern ENVIRONMENT_VARIABLE_PATTERN =
45+
Pattern.compile("\\$\\{(?!\\{)(.*?)\\}");
4246

43-
private final Map<String, String> envVars;
44-
private final ObjectMapper objectMapper;
45-
private final boolean strict;
46-
47-
private EnvVarResolver(Map<String, String> envVars, boolean strict) {
48-
this.envVars = envVars;
49-
this.strict = strict;
50-
objectMapper = initObjectMapper();
51-
}
52-
53-
/**
54-
* Creates a strict resolver backed by the current process environment.
55-
*
56-
* @return a resolver backed by {@link System#getenv()}
57-
*/
58-
public static EnvVarResolver of() {
59-
return of(true);
60-
}
61-
62-
/**
63-
* Creates a resolver backed by the current process environment.
64-
*
65-
* @param strict whether missing variables should fail resolution
66-
* @return a resolver backed by {@link System#getenv()}
67-
*/
68-
public static EnvVarResolver of(boolean strict) {
69-
return of(System.getenv(), strict);
70-
}
71-
72-
/**
73-
* Creates a strict resolver backed by the supplied environment variables.
74-
*
75-
* @param envVars environment variables used for placeholder resolution
76-
* @return a resolver backed by the supplied environment variables
77-
*/
78-
public static EnvVarResolver of(Map<String, String> envVars) {
79-
return of(envVars, true);
80-
}
81-
82-
/**
83-
* Creates a resolver backed by the supplied environment variables.
84-
*
85-
* @param envVars environment variables used for placeholder resolution
86-
* @param strict whether missing variables should fail resolution
87-
* @return a resolver backed by the supplied environment variables
88-
*/
89-
public static EnvVarResolver of(Map<String, String> envVars, boolean strict) {
90-
return new EnvVarResolver(envVars, strict);
91-
}
92-
93-
/**
94-
* Creates a strict resolver backed by the current process environment plus deployment defaults.
95-
*
96-
* @return a resolver with deployment defaults applied
97-
*/
98-
public static EnvVarResolver withDeploymentDefaults() {
99-
return withDeploymentDefaults(true);
100-
}
101-
102-
/**
103-
* Creates a resolver backed by the current process environment plus deployment defaults.
104-
*
105-
* @param strict whether missing variables should fail resolution
106-
* @return a resolver with deployment defaults applied
107-
*/
108-
public static EnvVarResolver withDeploymentDefaults(boolean strict) {
109-
return new EnvVarResolver(EnvUtils.getEnvWithDeploymentDefaults(), strict);
110-
}
111-
112-
/**
113-
* Creates a strict resolver backed by the supplied environment variables plus deployment
114-
* defaults.
115-
*
116-
* @param envVars environment variables used for placeholder resolution
117-
* @return a resolver with deployment defaults applied
118-
*/
119-
public static EnvVarResolver withDeploymentDefaults(Map<String, String> envVars) {
120-
return withDeploymentDefaults(envVars, true);
121-
}
122-
123-
/**
124-
* Creates a resolver backed by the supplied environment variables plus deployment defaults.
125-
*
126-
* <p>Deployment defaults are only added when the supplied map does not already contain those
127-
* keys.
128-
*
129-
* @param envVars environment variables used for placeholder resolution
130-
* @param strict whether missing variables should fail resolution
131-
* @return a resolver with deployment defaults applied
132-
*/
133-
public static EnvVarResolver withDeploymentDefaults(Map<String, String> envVars, boolean strict) {
134-
var modifiedEnvVars = new HashMap<>(envVars);
135-
EnvUtils.getDeploymentDefaults().forEach(modifiedEnvVars::putIfAbsent);
136-
137-
return new EnvVarResolver(Map.copyOf(modifiedEnvVars), strict);
138-
}
47+
@Builder.Default private final Map<String, String> envVars = System.getenv();
48+
@Builder.Default private final boolean strict = true;
49+
@Builder.Default private final Set<String> exclusions = Set.of();
13950

14051
/**
14152
* Resolves environment variables referenced in a given source string. Searches for environment
@@ -174,6 +85,11 @@ public String resolve(String src) {
17485
key = rawKey;
17586
}
17687

88+
// If excluded, don't do anything
89+
if (exclusions.contains(key)) {
90+
continue;
91+
}
92+
17793
if (envVars.containsKey(key)) {
17894
var envValue = envVars.get(key);
17995
matcher.appendReplacement(res, Matcher.quoteReplacement(envValue));
@@ -205,6 +121,7 @@ public String resolve(String src) {
205121
* @throws IOException if the JSON processing fails in any way
206122
*/
207123
public String resolveInJson(String jsonSrc) throws IOException {
124+
var objectMapper = initObjectMapper();
208125
var res = objectMapper.readValue(jsonSrc, Map.class);
209126

210127
return objectMapper.writeValueAsString(res);

env-utils/src/test/java/com/datasqrl/flinkrunner/utils/EnvVarResolverTest.java

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020

2121
import com.fasterxml.jackson.databind.ObjectMapper;
2222
import java.io.IOException;
23+
import java.util.HashMap;
2324
import java.util.Map;
25+
import java.util.Set;
26+
import lombok.experimental.SuperBuilder;
2427
import org.junit.jupiter.api.Test;
2528
import org.junit.jupiter.params.ParameterizedTest;
2629
import org.junit.jupiter.params.provider.CsvSource;
@@ -43,7 +46,7 @@ void givenEnvVariables_whenReplaceWithEnv_thenReplaceCorrectlyVars(
4346
Map.of(
4447
"USER", "John",
4548
"PATH", "/usr/bin");
46-
resolver = EnvVarResolver.of(envVariables);
49+
resolver = EnvVarResolver.builder().envVars(envVariables).build();
4750
var result = resolver.resolve(command);
4851
assertThat(result).isEqualTo(expected);
4952
}
@@ -57,7 +60,7 @@ void givenEnvVariables_whenReplaceWithEnv_thenReplaceCorrectlyVars(
5760
})
5861
void givenMissingEnvVariables_whenResolveEnv_Vars_thenThrowException(String command) {
5962
Map<String, String> envVariables = Map.of();
60-
resolver = EnvVarResolver.of(envVariables); // Empty map to simulate missing variables
63+
resolver = EnvVarResolver.builder().envVars(envVariables).build();
6164
assertThatThrownBy(() -> resolver.resolve(command))
6265
.isInstanceOf(IllegalStateException.class)
6366
.hasMessageStartingWith(
@@ -76,7 +79,7 @@ void givenSimilarEnvVariableNames_whenResolveEnv_Vars_thenPartialMatchDoesNotOcc
7679
Map.of(
7780
"USER", "John",
7881
"NAME", "exists");
79-
resolver = EnvVarResolver.of(envVariables);
82+
resolver = EnvVarResolver.builder().envVars(envVariables).build();
8083
assertThatThrownBy(() -> resolver.resolve(command))
8184
.isInstanceOf(IllegalStateException.class)
8285
.hasMessage(
@@ -105,7 +108,7 @@ void givenDefaultEnvValues_whenResolve_thenFallbackOrUseEnvValue(
105108
Map.of(
106109
"USER", "John",
107110
"PATH", "/usr/bin");
108-
resolver = EnvVarResolver.of(envVariables);
111+
resolver = EnvVarResolver.builder().envVars(envVariables).build();
109112
var result = resolver.resolve(command);
110113
assertThat(result).isEqualTo(expected);
111114
}
@@ -120,7 +123,7 @@ void givenDefaultEnvValues_whenResolve_thenFallbackOrUseEnvValue(
120123
})
121124
void givenMissingEnvWithoutDefault_whenResolve_thenThrowException(String command) {
122125
Map<String, String> envVariables = Map.of("A", "something");
123-
resolver = EnvVarResolver.of(envVariables);
126+
resolver = EnvVarResolver.builder().envVars(envVariables).build();
124127
assertThatThrownBy(() -> resolver.resolve(command))
125128
.isInstanceOf(IllegalStateException.class)
126129
.hasMessageContaining("referenced, but not found");
@@ -136,22 +139,52 @@ void givenMissingEnvWithoutDefault_whenResolve_thenThrowException(String command
136139
})
137140
void givenFallbackWithColons_whenResolve_thenParseCorrectly(String command, String expected) {
138141
Map<String, String> envVariables = Map.of(); // No TOKEN set
139-
resolver = EnvVarResolver.of(envVariables);
142+
resolver = EnvVarResolver.builder().envVars(envVariables).build();
140143
var result = resolver.resolve(command);
141144
assertThat(result).isEqualTo(expected);
142145
}
143146

147+
@ParameterizedTest
148+
@CsvSource({
149+
"'${{SECRET}}', '${{SECRET}}'",
150+
"'Token=${{SECRET}}', 'Token=${{SECRET}}'",
151+
"'Host=${HOST}, password=${{DB_PASSWORD}}', 'Host=db.example.com, password=${{DB_PASSWORD}}'"
152+
})
153+
void givenSecretEnvTemplate_whenResolve_thenLeaveUnchanged(String command, String expected) {
154+
resolver = EnvVarResolver.builder().envVars(Map.of("HOST", "db.example.com")).build();
155+
156+
var result = resolver.resolve(command);
157+
158+
assertThat(result).isEqualTo(expected);
159+
}
160+
161+
@Test
162+
void givenExcludedEnvVariables_whenResolve_thenLeavePlaceholdersUnchanged() {
163+
resolver =
164+
EnvVarResolver.builder()
165+
.envVars(
166+
Map.of(
167+
"USER", "John",
168+
"EXCLUDED", "ignored"))
169+
.exclusions(Set.of("EXCLUDED", "MISSING_EXCLUDED"))
170+
.build();
171+
172+
var result = resolver.resolve("${USER}|${EXCLUDED}|${EXCLUDED:-fallback}|${MISSING_EXCLUDED}");
173+
174+
assertThat(result).isEqualTo("John|${EXCLUDED}|${EXCLUDED:-fallback}|${MISSING_EXCLUDED}");
175+
}
176+
144177
@Test
145178
void givenNullOrBlankSource_whenResolve_thenReturnSource() {
146-
resolver = EnvVarResolver.of(Map.of());
179+
resolver = EnvVarResolver.builder().envVars(Map.of()).build();
147180

148181
assertThat(resolver.resolve(null)).isNull();
149182
assertThat(resolver.resolve(" ")).isEqualTo(" ");
150183
}
151184

152185
@Test
153186
void givenNonStrictResolver_whenMissingEnvVariables_thenLeavesPlaceholdersUnresolved() {
154-
resolver = EnvVarResolver.of(Map.of("USER", "John"), false);
187+
resolver = EnvVarResolver.builder().envVars(Map.of("USER", "John")).strict(false).build();
155188

156189
var result = resolver.resolve("Hello ${USER}, ${MISSING}!");
157190

@@ -161,10 +194,13 @@ void givenNonStrictResolver_whenMissingEnvVariables_thenLeavesPlaceholdersUnreso
161194
@Test
162195
void givenDeploymentDefaults_whenResolve_thenUseDefaultsAndSuppliedValues() {
163196
resolver =
164-
EnvVarResolver.withDeploymentDefaults(
165-
Map.of(
166-
"DEPLOYMENT_ID", "deployment-1",
167-
"USER", "John"));
197+
EnvVarResolver.builder()
198+
.envVars(
199+
withDeploymentDefaults(
200+
Map.of(
201+
"DEPLOYMENT_ID", "deployment-1",
202+
"USER", "John")))
203+
.build();
168204

169205
var result = resolver.resolve("${DEPLOYMENT_ID}|${DEPLOYMENT_TIMESTAMP}|${USER}");
170206
var parts = result.split("\\|");
@@ -178,7 +214,10 @@ void givenDeploymentDefaults_whenResolve_thenUseDefaultsAndSuppliedValues() {
178214
@Test
179215
void givenNonStrictDeploymentDefaults_whenMissingNonDefaultEnvVariable_thenLeavesPlaceholder() {
180216
resolver =
181-
EnvVarResolver.withDeploymentDefaults(Map.of("DEPLOYMENT_ID", "deployment-1"), false);
217+
EnvVarResolver.builder()
218+
.envVars(withDeploymentDefaults(Map.of("DEPLOYMENT_ID", "deployment-1")))
219+
.strict(false)
220+
.build();
182221

183222
var result = resolver.resolve("${DEPLOYMENT_ID}|${MISSING}");
184223

@@ -187,7 +226,7 @@ void givenNonStrictDeploymentDefaults_whenMissingNonDefaultEnvVariable_thenLeave
187226

188227
@Test
189228
void givenJsonSource_whenResolveInJson_thenResolveStringLeafNodes() throws IOException {
190-
resolver = EnvVarResolver.of(Map.of("USER", "John"));
229+
resolver = EnvVarResolver.builder().envVars(Map.of("USER", "John")).build();
191230

192231
var result =
193232
resolver.resolveInJson(
@@ -198,4 +237,36 @@ void givenJsonSource_whenResolveInJson_thenResolveStringLeafNodes() throws IOExc
198237
assertThat(json.get("count").asInt()).isEqualTo(1);
199238
assertThat(json.get("nested").get("path").asText()).isEqualTo("/tmp");
200239
}
240+
241+
@Test
242+
void givenSubclass_whenBuild_thenInheritedBuilderFieldsAreApplied() {
243+
resolver =
244+
TestEnvVarResolver.builder()
245+
.envVars(Map.of("USER", "John"))
246+
.strict(false)
247+
.prefix("resolved=")
248+
.build();
249+
250+
var result = resolver.resolve("${USER}:${MISSING}");
251+
252+
assertThat(result).isEqualTo("resolved=John:${MISSING}");
253+
}
254+
255+
private Map<String, String> withDeploymentDefaults(Map<String, String> envVars) {
256+
var modifiedEnvVars = new HashMap<>(envVars);
257+
EnvUtils.getDeploymentDefaults().forEach(modifiedEnvVars::putIfAbsent);
258+
259+
return Map.copyOf(modifiedEnvVars);
260+
}
261+
262+
@SuperBuilder
263+
private static class TestEnvVarResolver extends EnvVarResolver {
264+
265+
private final String prefix;
266+
267+
@Override
268+
public String resolve(String src) {
269+
return prefix + super.resolve(src);
270+
}
271+
}
201272
}

flink-sql-runner/src/main/java/com/datasqrl/flinkrunner/CliRunner.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package com.datasqrl.flinkrunner;
1717

18+
import com.datasqrl.flinkrunner.utils.EnvUtils;
1819
import com.datasqrl.flinkrunner.utils.EnvVarResolver;
1920
import java.util.Arrays;
2021
import java.util.concurrent.Callable;
@@ -76,7 +77,13 @@ public CliRunner(
7677
@Nullable String planFile,
7778
@Nullable String configDir,
7879
@Nullable String udfPath) {
79-
this(mode, EnvVarResolver.withDeploymentDefaults(), sqlFile, planFile, configDir, udfPath);
80+
this(
81+
mode,
82+
EnvVarResolver.builder().envVars(EnvUtils.getEnvWithDeploymentDefaults()).build(),
83+
sqlFile,
84+
planFile,
85+
configDir,
86+
udfPath);
8087
}
8188

8289
@VisibleForTesting

0 commit comments

Comments
 (0)