Skip to content

Commit 0140812

Browse files
committed
Bridge: variable expansion for config files
Config files can now use "dollar brace" notation to do variable substitutions. See the readme for an example. 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."_
1 parent cd5e1ba commit 0140812

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)