Skip to content

Commit 7339efb

Browse files
committed
feat(wash): dev.environment for host process env vars
Add a `dev.environment` map that wash dev exports into its own process before constructing the host, so plugins and runtime crates that read from std::env (RUST_LOG, OTEL_*, libpq's PG*, ...) pick the values up. Distinct from workload.environment, which is delivered to the component via wasi:cli/env. Also add the first schema tests for the Config struct, pinning the camelCase contract on WorkloadConfig / EnvironmentLayer / ConfigSource so a future refactor that drops a `rename_all` doesn't silently drop user-configured fields. Signed-off-by: Bailey Hayes <bailey@cosmonic.com>
1 parent df6d42f commit 7339efb

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

crates/wash/src/cli/dev.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ impl CliCommand for DevCommand {
5656
.context("failed to load config for development")?;
5757

5858
let dev_config = config.dev();
59+
60+
// Export `dev.environment` into the wash process so plugins and
61+
// runtime crates that read from `std::env` (tracing, OTEL exporters,
62+
// libpq, …) see them. We do this before constructing the host so the
63+
// first time a plugin reads its config, the values are present.
64+
//
65+
// SAFETY: `std::env::set_var` is unsafe in the 2024 edition because
66+
// it races with concurrent `getenv` from other threads. At this point
67+
// we're still on the command's entrypoint task. The host hasn't been
68+
// built and no plugin has begun reading env. Tokio worker threads
69+
// exist but none of our code reads env from them yet. Apply once,
70+
// up front, and never touch the env again.
71+
#[allow(unsafe_code)]
72+
for (key, value) in &dev_config.environment {
73+
unsafe { std::env::set_var(key, value) };
74+
}
75+
5976
let http_addr = dev_config
6077
.address
6178
.clone()

crates/wash/src/config.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,14 @@ pub struct DevConfig {
241241
#[serde(default, skip_serializing_if = "Vec::is_empty")]
242242
pub volumes: Vec<DevVolume>,
243243

244+
/// Environment variables exported into the `wash dev` process before the
245+
/// host is built. Surfaces values to plugins and runtime crates that read
246+
/// from `std::env` (e.g. `RUST_LOG`, `OTEL_*`, libpq's `PG*` family).
247+
/// Distinct from `workload.environment`, which is delivered to the
248+
/// component via `wasi:cli/env`.
249+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
250+
pub environment: HashMap<String, String>,
251+
244252
/// Host interfaces configuration
245253
#[serde(default, skip_serializing_if = "Vec::is_empty")]
246254
pub host_interfaces: Vec<WitInterface>,
@@ -434,3 +442,115 @@ pub async fn generate_default_config(path: &Path, force: bool) -> Result<()> {
434442
info!(config_path = %path.display(), "Generated default configuration");
435443
Ok(())
436444
}
445+
446+
#[cfg(test)]
447+
mod tests {
448+
use super::*;
449+
450+
#[test]
451+
fn dev_environment_deserializes_from_yaml() {
452+
// Locks in the YAML contract surfaced to users:
453+
//
454+
// dev:
455+
// environment:
456+
// KEY: value
457+
//
458+
// A regression here (e.g. someone adding `rename_all = "camelCase"`
459+
// to `DevConfig`, or moving the field) would silently drop user-
460+
// configured env vars at `wash dev` startup.
461+
let yaml = r#"
462+
dev:
463+
environment:
464+
RUST_LOG: debug
465+
OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4317
466+
"#;
467+
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
468+
let env = config.dev().environment;
469+
assert_eq!(env.get("RUST_LOG").map(String::as_str), Some("debug"));
470+
assert_eq!(
471+
env.get("OTEL_EXPORTER_OTLP_ENDPOINT").map(String::as_str),
472+
Some("http://localhost:4317")
473+
);
474+
}
475+
476+
#[test]
477+
fn workload_yaml_uses_camel_case_for_renamed_fields() {
478+
// `WorkloadConfig`, `EnvironmentLayer`, and `ConfigSource` carry
479+
// `rename_all = "camelCase"`. Users write `configFrom` / `secretFrom`
480+
// / `allowedHosts` / `fromEnv` in YAML; if a refactor drops one of
481+
// those `rename_all` attributes, the camelCase keys get silently
482+
// dropped (parses fine, fields stay default). Pin the contract.
483+
let yaml = r#"
484+
workload:
485+
environment:
486+
config:
487+
INLINE_KEY: inline_value
488+
configFrom:
489+
- app
490+
secretFrom:
491+
- creds
492+
config:
493+
flag: "on"
494+
allowedHosts:
495+
- https://api.example.com
496+
"#;
497+
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
498+
let workload = config.workload.expect("workload should parse");
499+
500+
let env = workload
501+
.environment
502+
.expect("environment layer should parse");
503+
assert_eq!(env.config.get("INLINE_KEY").unwrap(), "inline_value");
504+
assert_eq!(env.config_from, vec!["app".to_string()]);
505+
assert_eq!(env.secret_from, vec!["creds".to_string()]);
506+
507+
assert_eq!(workload.config.get("flag").unwrap(), "on");
508+
assert_eq!(
509+
workload.allowed_hosts,
510+
vec!["https://api.example.com".to_string()]
511+
);
512+
}
513+
514+
#[test]
515+
fn configs_and_secrets_named_map_with_camel_case_source_fields() {
516+
// The top-level `configs:` and `secrets:` blocks are name -> ConfigSource
517+
// maps, and ConfigSource's `from_env` field is `fromEnv` in YAML.
518+
// `secrets:` shares the same struct as `configs:` — pin both so a
519+
// future split into separate types doesn't silently lose schema parity.
520+
let yaml = r#"
521+
configs:
522+
app:
523+
inline:
524+
APP_FOO: app_foo_value
525+
file: ./app.env
526+
secrets:
527+
creds:
528+
fromEnv:
529+
- DB_PASSWORD
530+
inline:
531+
DB_USER: alice
532+
"#;
533+
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
534+
535+
let app = config.configs.get("app").expect("configs.app should parse");
536+
assert_eq!(app.inline.get("APP_FOO").unwrap(), "app_foo_value");
537+
assert_eq!(app.file.as_deref(), Some(Path::new("./app.env")));
538+
539+
let creds = config
540+
.secrets
541+
.get("creds")
542+
.expect("secrets.creds should parse");
543+
assert_eq!(creds.from_env, vec!["DB_PASSWORD".to_string()]);
544+
assert_eq!(creds.inline.get("DB_USER").unwrap(), "alice");
545+
}
546+
547+
#[test]
548+
fn dev_environment_defaults_to_empty() {
549+
// `dev.environment` is optional — a `dev:` block without it must
550+
// not fail to parse, and must produce an empty map (not panic on
551+
// the `set_var` loop reading a None).
552+
let yaml = "dev: {}\n";
553+
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
554+
assert!(config.dev().environment.is_empty());
555+
}
556+
}

0 commit comments

Comments
 (0)