feat(brain-realtime): API-key loader + env-var/file-path posture (spec §5.3)
OPENAI_API_KEY default + OPENAI_API_KEY_FILE override (file wins — k8s-secret pattern). Trims trailing whitespace (some k8s mounts add newlines). KMS/Vault integration is step-6; the file-path override makes secret-manager injection trivial later. TDD: 5 tests (env-set, file-overrides-env, trim-newline, missing-file, neither-set); verified deliberately-introduced-bug failure (upper() breaks the env-var assertion).
This commit is contained in:
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -781,6 +781,12 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1144,6 +1150,19 @@ dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
@@ -1192,6 +1211,8 @@ dependencies = [
|
||||
"rutster-tap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
@@ -1515,6 +1536,19 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
@@ -19,6 +19,10 @@ tracing-subscriber = { workspace = true }
|
||||
url = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -1,2 +1,199 @@
|
||||
//! API-key loader (spec §5.3) — implements the env-var + file-path posture.
|
||||
//! Filled in by Task 3.
|
||||
//! # API-key loader (spec §5.3)
|
||||
//!
|
||||
//! Two-source config: `OPENAI_API_KEY` env var default + optional
|
||||
//! `OPENAI_API_KEY_FILE` path override. KMS/Vault integration is deferred
|
||||
//! to step 6 (spec §1.2); the file-path override makes secret-manager
|
||||
//! injection (k8s secrets, Vault agent) trivial when that layer exists.
|
||||
//!
|
||||
//! # Why file-path override (not env-only)
|
||||
//!
|
||||
//! A k8s pod mounts secrets at file paths (e.g. `/var/secrets/openai_key`).
|
||||
//! Env-var secrets are also fine, but file-path is the standard pattern for
|
||||
//! k8s + Vault agent + sidecar secret rotators. The dev loop uses the env
|
||||
//! var path (single-source, simplest).
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiKeyError {
|
||||
#[error("no API key: set OPENAI_API_KEY or OPENAI_API_KEY_FILE")]
|
||||
NotFound,
|
||||
#[error("OPENAI_API_KEY_FILE path is not valid UTF-8: {0:?}")]
|
||||
PathNotUtf8(PathBuf),
|
||||
#[error("failed to read OPENAI_API_KEY_FILE at {path}: {source}")]
|
||||
FileRead {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Load the OpenAI API key per the env-var + file-path posture (spec §5.3).
|
||||
///
|
||||
/// Precedence: `OPENAI_API_KEY_FILE` wins over `OPENAI_API_KEY` (a file
|
||||
/// path is a more specific override; this matches k8s-secret patterns where
|
||||
/// an operator mounts a file to override the default env var).
|
||||
///
|
||||
/// Trims trailing whitespace from either source (some k8s secret mounts
|
||||
/// add trailing newlines).
|
||||
pub fn load_api_key() -> Result<String, ApiKeyError> {
|
||||
if let Ok(path_str) = env::var("OPENAI_API_KEY_FILE") {
|
||||
let path = PathBuf::from(&path_str);
|
||||
let raw = fs::read_to_string(&path).map_err(|source| ApiKeyError::FileRead {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
return Ok(raw.trim().to_string());
|
||||
}
|
||||
if let Ok(raw) = env::var("OPENAI_API_KEY") {
|
||||
return Ok(raw.trim().to_string());
|
||||
}
|
||||
Err(ApiKeyError::NotFound)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// NOTE: edition 2024 makes env::set_var / env::remove_var `unsafe` (they
|
||||
// can race with concurrent reads of the process environ). The brief was
|
||||
// written against edition 2021 syntax; we wrap each mutation in
|
||||
// `unsafe { ... }` rather than reverting to a pre-2024 edition. The
|
||||
// unsafety is local to the test process and accepted by the stdlib's
|
||||
// own test helpers; no threads here read these env vars concurrently.
|
||||
|
||||
// The process environ is process-global state. Even with the save/restore
|
||||
// pattern below, running these tests in parallel would let one test's
|
||||
// `set_var` race against another's `remove_var` for the same variable,
|
||||
// producing flaky failures. This mutex serializes every test in this
|
||||
// module so the env-var mutations are atomic across tests.
|
||||
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Acquire the env mutex for the test body. Panics if the lock is held
|
||||
/// (only happens if a test panics while held — fine, fail fast).
|
||||
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
||||
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_when_neither_env_nor_file_set() {
|
||||
let _guard = lock_env();
|
||||
// Save then clear both env vars to make the test order-independent.
|
||||
let saved_key = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
|
||||
let saved_file =
|
||||
env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
|
||||
unsafe {
|
||||
env::remove_var("OPENAI_API_KEY");
|
||||
env::remove_var("OPENAI_API_KEY_FILE");
|
||||
}
|
||||
|
||||
let r = load_api_key();
|
||||
assert!(matches!(r, Err(ApiKeyError::NotFound)));
|
||||
|
||||
// Restore for any other test in the same process.
|
||||
if let Some((k, v)) = saved_key {
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
}
|
||||
if let Some((k, v)) = saved_file {
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_from_env_var() {
|
||||
let _guard = lock_env();
|
||||
let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
|
||||
let saved_file =
|
||||
env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
|
||||
unsafe {
|
||||
env::set_var("OPENAI_API_KEY", "sk-test-12345");
|
||||
env::remove_var("OPENAI_API_KEY_FILE");
|
||||
}
|
||||
|
||||
let r = load_api_key().unwrap();
|
||||
assert_eq!(r, "sk-test-12345");
|
||||
|
||||
if let Some((k, v)) = saved {
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
env::remove_var("OPENAI_API_KEY");
|
||||
}
|
||||
}
|
||||
if let Some((k, v)) = saved_file {
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_newline_from_env() {
|
||||
let _guard = lock_env();
|
||||
let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
|
||||
unsafe {
|
||||
env::set_var("OPENAI_API_KEY", "sk-test-with-newline\n");
|
||||
env::remove_var("OPENAI_API_KEY_FILE");
|
||||
}
|
||||
|
||||
let r = load_api_key().unwrap();
|
||||
assert_eq!(r, "sk-test-with-newline");
|
||||
|
||||
if let Some((k, v)) = saved {
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
env::remove_var("OPENAI_API_KEY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_path_overrides_env() {
|
||||
let _guard = lock_env();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("key.txt");
|
||||
std::fs::write(&path, "sk-from-file-98765\n").unwrap();
|
||||
|
||||
unsafe {
|
||||
env::set_var("OPENAI_API_KEY", "sk-from-env-should-be-overridden");
|
||||
env::set_var("OPENAI_API_KEY_FILE", path.to_str().unwrap());
|
||||
}
|
||||
|
||||
let r = load_api_key().unwrap();
|
||||
assert_eq!(r, "sk-from-file-98765");
|
||||
|
||||
unsafe {
|
||||
env::remove_var("OPENAI_API_KEY");
|
||||
env::remove_var("OPENAI_API_KEY_FILE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_path_missing_returns_file_read_error() {
|
||||
let _guard = lock_env();
|
||||
unsafe {
|
||||
env::set_var("OPENAI_API_KEY_FILE", "/nonexistent/path/to/key.txt");
|
||||
env::remove_var("OPENAI_API_KEY");
|
||||
}
|
||||
|
||||
let r = load_api_key();
|
||||
assert!(matches!(r, Err(ApiKeyError::FileRead { .. })));
|
||||
|
||||
unsafe {
|
||||
env::remove_var("OPENAI_API_KEY_FILE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user