Skip to content

Commit dd242a8

Browse files
authored
Bridge: variable expansion for config files (#986)
Config files can now use "dollar brace" notation to do variable substitutions. See the readme for an example. ~~Since variable expansion might not be assumed, this behavior is gated by a CLI flag/env var and is **off by default**.~~ Furthermore, the variables available for substitution are exposed via a simple HashMap meaning we can filter the vars exposed in the future, if requested. This also has the benefit of being nicer for testing ;) Tests are added to capture the initial behavior of the new dep, but also to lock in the contract if we decided to "roll our own" in the future. --- Open source review required. Adds a dependency on [envsubst-rs](https://github.com/coreos/envsubst-rs) to provide the base substitution capability. This was selected based on the following criteria: - support for the absolute minimum features (no expression evaluation for fallbacks, etc). - Tiny. 170LOC (60+ of which are tests), easy to understand and fork if needed. - Vaguely looks maintained, owned by coreos (part of redhat). The big idea with adding this dep is, _"it already exists, but rolling our own based on this if we want to customize behavior is easy."_
2 parents cd5e1ba + 0140812 commit dd242a8

File tree

6 files changed

+222
-6
lines changed

6 files changed

+222
-6
lines changed

bridge/Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bridge/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,27 @@ The config file itself does the heavy lifting.
133133
134134
When unset, the current working directory is checked for a file named `svix-bridge.yaml`.
135135
136+
## Variable Expansion
137+
138+
`svix-bridge` supports environment variable expansion inside the config file.
139+
140+
Using "dollar brace" notation, e.g. `${MY_VARIABLE}`, a substitution will be made from the environment.
141+
If the variable lookup fails, the raw variable text is left in the config as-is.
142+
143+
As an example, here's a RabbitMQ sender configured with environment variables:
144+
145+
```yaml
146+
senders:
147+
- name: "send-webhooks-from-${QUEUE_NAME}"
148+
input:
149+
type: "rabbitmq"
150+
uri: "${MQ_URI}"
151+
queue_name: "${QUEUE_NAME}"
152+
output:
153+
type: "svix"
154+
token: "${SVIX_TOKEN}"
155+
```
156+
136157
Each sender and receiver can optionally specify a `transformation`.
137158
Transformations should define a function called `handler` that accepts an object and returns an object.
138159

bridge/svix-bridge/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ anyhow = "1"
1010
clap = { version = "4.2.4", features = ["env", "derive"] }
1111
axum = { version = "0.6", features = ["macros"] }
1212
enum_dispatch = "0.3"
13+
envsubst = "0.2.1"
1314
http = "0.2"
1415
hyper = { version = "0.14", features = ["full"] }
1516
lazy_static = "1.4"

bridge/svix-bridge/src/config/mod.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
use serde::Deserialize;
2+
use std::borrow::Cow;
3+
use std::collections::HashMap;
4+
use std::io::{Error, ErrorKind};
25
use std::net::SocketAddr;
36
use svix_bridge_plugin_queue::config::{
47
into_receiver_output, QueueConsumerConfig, ReceiverOutputOpts as QueueOutOpts,
@@ -26,6 +29,28 @@ pub struct Config {
2629
pub http_listen_address: SocketAddr,
2730
}
2831

32+
impl Config {
33+
/// Build a Config from yaml source.
34+
/// Optionally accepts a map to perform variable substitution with.
35+
pub fn from_src(
36+
raw_src: &str,
37+
vars: Option<&HashMap<String, String>>,
38+
) -> std::io::Result<Self> {
39+
let src = if let Some(vars) = vars {
40+
Cow::Owned(envsubst::substitute(raw_src, vars).map_err(|e| {
41+
Error::new(
42+
ErrorKind::Other,
43+
format!("Variable substitution failed: {e}"),
44+
)
45+
})?)
46+
} else {
47+
Cow::Borrowed(raw_src)
48+
};
49+
serde_yaml::from_str(&src)
50+
.map_err(|e| Error::new(ErrorKind::Other, format!("Failed to parse config: {}", e)))
51+
}
52+
}
53+
2954
fn default_http_listen_address() -> SocketAddr {
3055
"0.0.0.0:5000".parse().expect("default http listen address")
3156
}

bridge/svix-bridge/src/config/tests.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
use super::Config;
22
use crate::config::{LogFormat, LogLevel, SenderConfig};
3+
use std::collections::HashMap;
4+
use svix_bridge_plugin_queue::config::{QueueConsumerConfig, RabbitMqInputOpts, SenderInputOpts};
5+
use svix_bridge_types::{SenderOutputOpts, SvixSenderOutputOpts};
36

47
/// This is meant to be a kitchen sink config, hitting as many possible
58
/// configuration options as possible to ensure they parse correctly.
@@ -331,3 +334,154 @@ fn test_senders_example() {
331334
assert!(!conf.senders.is_empty());
332335
assert!(conf.receivers.is_empty());
333336
}
337+
338+
#[test]
339+
fn test_variable_substitution_missing_vars() {
340+
let src = r#"
341+
opentelemetry:
342+
address: "${OTEL_ADDR}"
343+
"#;
344+
let vars = HashMap::new();
345+
let cfg = Config::from_src(src, Some(&vars)).unwrap();
346+
let otel = cfg.opentelemetry.unwrap();
347+
// when lookups in the vars map fail, the original token text is preserved.
348+
assert_eq!(&otel.address, "${OTEL_ADDR}");
349+
}
350+
351+
#[test]
352+
fn test_variable_substitution_available_vars() {
353+
let src = r#"
354+
opentelemetry:
355+
address: "${OTEL_ADDR}"
356+
sample_ratio: ${OTEL_SAMPLE_RATIO}
357+
"#;
358+
let mut vars = HashMap::new();
359+
vars.insert(
360+
String::from("OTEL_ADDR"),
361+
String::from("http://127.0.0.1:8080"),
362+
);
363+
vars.insert(String::from("OTEL_SAMPLE_RATIO"), String::from("0.25"));
364+
let cfg = Config::from_src(src, Some(&vars)).unwrap();
365+
// when lookups succeed, the token should be replaced.
366+
let otel = cfg.opentelemetry.unwrap();
367+
assert_eq!(&otel.address, "http://127.0.0.1:8080");
368+
assert_eq!(otel.sample_ratio, Some(0.25));
369+
}
370+
371+
#[test]
372+
fn test_variable_substitution_requires_braces() {
373+
let src = r#"
374+
opentelemetry:
375+
# Neglecting to use ${} notation means the port number will not be substituted.
376+
address: "${OTEL_SCHEME}://${OTEL_HOST}:$OTEL_PORT"
377+
"#;
378+
let mut vars = HashMap::new();
379+
vars.insert(String::from("OTEL_SCHEME"), String::from("https"));
380+
vars.insert(String::from("OTEL_HOST"), String::from("127.0.0.1"));
381+
vars.insert(String::from("OTEL_PORT"), String::from("9999"));
382+
let cfg = Config::from_src(src, Some(&vars)).unwrap();
383+
// when lookups succeed, the token should be replaced.
384+
let otel = cfg.opentelemetry.unwrap();
385+
// Not the user-intended outcome, but it simplifies the parsing requirements.
386+
assert_eq!(&otel.address, "https://127.0.0.1:$OTEL_PORT");
387+
}
388+
389+
#[test]
390+
fn test_variable_substitution_missing_numeric_var_is_err() {
391+
// Unfortunate side-effect of templating yaml.
392+
//
393+
// If the variable is missing, usually you've got three options:
394+
// - retain the token text that failed the lookup (envsubst-rs does this)
395+
// - replace the token with an empty string (the CLI `envsubst` does this)
396+
// - mark it an error (neither do this, but we can if we roll our own impl)
397+
//
398+
// For yaml, the field typings are heavily/poorly inferred so for an optional float like
399+
// `sample_ratio` an empty string would parse as a `None`, which could be a bad fallback since
400+
// otel considers this a 1.0 ratio (send everything).
401+
//
402+
// For this specific case, retaining the token text produces an error, which happens to be useful.
403+
// For fields that happen to be strings anyway, errors may show up later (after the config parsing).
404+
// Ex: using `${QUEUE_NAME}` in a rabbit sender input will surface in logs as an error when we
405+
// try to connect: "no such queue '${QUEUE_NAME}'".
406+
407+
let src = r#"
408+
opentelemetry:
409+
address: "${OTEL_ADDR}"
410+
# This var will be missing, causing the template token to
411+
# be retained causing a parse failure :(
412+
sample_ratio: ${OTEL_SAMPLE_RATIO}
413+
"#;
414+
let vars = HashMap::new();
415+
let err = Config::from_src(src, Some(&vars)).err().unwrap();
416+
let want = "Failed to parse config: opentelemetry.sample_ratio: invalid type: \
417+
string \"${OTEL_SAMPLE_RATIO}\", expected f64 at line 6 column 23";
418+
assert_eq!(want, err.to_string());
419+
}
420+
421+
#[test]
422+
fn test_variable_substitution_repeated_lookups() {
423+
// This is probably a given, but we should expect a single variable can be referenced multiple
424+
// times within the config.
425+
// The concrete use case: auth tokens.
426+
427+
let src = r#"
428+
senders:
429+
- name: "rabbitmq-1"
430+
input:
431+
type: "rabbitmq"
432+
uri: "${RABBIT_URI}"
433+
queue_name: "${QUEUE_NAME_1}"
434+
output:
435+
type: "svix"
436+
token: "${SVIX_TOKEN}"
437+
- name: "rabbitmq-2"
438+
input:
439+
type: "rabbitmq"
440+
uri: "${RABBIT_URI}"
441+
queue_name: "${QUEUE_NAME_2}"
442+
output:
443+
type: "svix"
444+
token: "${SVIX_TOKEN}"
445+
"#;
446+
let mut vars = HashMap::new();
447+
vars.insert(
448+
String::from("RABBIT_URI"),
449+
String::from("amqp://guest:guest@localhost:5672/%2f"),
450+
);
451+
vars.insert(String::from("QUEUE_NAME_1"), String::from("one"));
452+
vars.insert(String::from("QUEUE_NAME_2"), String::from("two"));
453+
vars.insert(String::from("SVIX_TOKEN"), String::from("x"));
454+
let cfg = Config::from_src(src, Some(&vars)).unwrap();
455+
456+
if let SenderConfig::QueueConsumer(QueueConsumerConfig {
457+
input:
458+
SenderInputOpts::RabbitMQ(RabbitMqInputOpts {
459+
uri, queue_name, ..
460+
}),
461+
output: SenderOutputOpts::Svix(SvixSenderOutputOpts { token, .. }),
462+
..
463+
}) = &cfg.senders[0]
464+
{
465+
assert_eq!(uri, "amqp://guest:guest@localhost:5672/%2f");
466+
assert_eq!(queue_name, "one");
467+
assert_eq!(token, "x");
468+
} else {
469+
panic!("sender did not match expected pattern");
470+
}
471+
472+
if let SenderConfig::QueueConsumer(QueueConsumerConfig {
473+
input:
474+
SenderInputOpts::RabbitMQ(RabbitMqInputOpts {
475+
uri, queue_name, ..
476+
}),
477+
output: SenderOutputOpts::Svix(SvixSenderOutputOpts { token, .. }),
478+
..
479+
}) = &cfg.senders[1]
480+
{
481+
assert_eq!(uri, "amqp://guest:guest@localhost:5672/%2f");
482+
assert_eq!(queue_name, "two");
483+
assert_eq!(token, "x");
484+
} else {
485+
panic!("sender did not match expected pattern");
486+
}
487+
}

bridge/svix-bridge/src/main.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,23 @@ pub struct Args {
155155
async fn main() -> Result<()> {
156156
let args = Args::parse();
157157

158-
let config = args.cfg.unwrap_or_else(|| {
158+
let config_path = args.cfg.unwrap_or_else(|| {
159159
std::env::current_dir()
160160
.expect("current dir")
161161
.join("svix-bridge.yaml")
162162
});
163-
let cfg: Config = serde_yaml::from_str(&std::fs::read_to_string(&config).map_err(|e| {
164-
let p = config.into_os_string().into_string().expect("config path");
163+
164+
let cfg_source = std::fs::read_to_string(&config_path).map_err(|e| {
165+
let p = config_path
166+
.into_os_string()
167+
.into_string()
168+
.expect("config path");
165169
Error::new(ErrorKind::Other, format!("Failed to read {p}: {e}"))
166-
})?)
167-
.map_err(|e| Error::new(ErrorKind::Other, format!("Failed to parse config: {}", e)))?;
168-
setup_tracing(&cfg);
170+
})?;
169171

172+
let vars = std::env::vars().collect();
173+
let cfg = Config::from_src(&cfg_source, Some(vars).as_ref())?;
174+
setup_tracing(&cfg);
170175
tracing::info!("starting");
171176

172177
let (xform_tx, mut xform_rx) = tokio::sync::mpsc::unbounded_channel::<TransformerJob>();

0 commit comments

Comments
 (0)