feat(harmony_config): unified config layer (ADR-020) — ConfigClient, ConfigClass, masking #304

Merged
johnride merged 4 commits from pr/harmony-config-layer into master 2026-05-29 16:15:56 +00:00
14 changed files with 954 additions and 296 deletions

View File

@@ -9,7 +9,7 @@ Make `harmony_config` production-ready with a seamless first-run experience: clo
`harmony_config` now has: `harmony_config` now has:
- `Config` trait + `#[derive(Config)]` macro - `Config` trait + `#[derive(Config)]` macro
- `ConfigManager` with ordered source chain - `ConfigClient` with ordered source chain
- Five `ConfigSource` implementations: - Five `ConfigSource` implementations:
- `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars - `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars
- `LocalFileSource` — reads/writes `{key}.json` files from a directory - `LocalFileSource` — reads/writes `{key}.json` files from a directory
@@ -140,7 +140,7 @@ for source in &self.sources {
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ Harmony CLI / App │ │ Harmony CLI / App │
│ │ │ │
│ ConfigManager: │ ConfigClient:
│ 1. EnvSource ← HARMONY_CONFIG_* env vars (highest priority) │ │ 1. EnvSource ← HARMONY_CONFIG_* env vars (highest priority) │
│ 2. SqliteSource ← ~/.local/share/harmony/config/config.db │ │ 2. SqliteSource ← ~/.local/share/harmony/config/config.db │
│ 3. StoreSource ← OpenBao (team-scale, via Zitadel OIDC) │ │ 3. StoreSource ← OpenBao (team-scale, via Zitadel OIDC) │
@@ -448,11 +448,11 @@ The example uses `StoreSource<OpenbaoSecretStore>` with token auth to avoid the
| `OPENBAO_KV_MOUNT` | No | `"secret"` | KV v2 engine mount path. **Also used as userpass auth mount -- this is a bug.** | | `OPENBAO_KV_MOUNT` | No | `"secret"` | KV v2 engine mount path. **Also used as userpass auth mount -- this is a bug.** |
| `OPENBAO_SKIP_TLS` | No | `false` | Set `"true"` to disable TLS verification | | `OPENBAO_SKIP_TLS` | No | `false` | Set `"true"` to disable TLS verification |
**Note**: `OpenbaoSecretStore::new()` is `async` and **requires a running OpenBao** at construction time (it validates the token if using cached auth). If OpenBao is unreachable during construction, the call will fail. The graceful fallback only applies to `StoreSource::get()` calls after construction -- the `ConfigManager` must be built with a live store, or the store must be wrapped in a lazy initialization pattern. **Note**: `OpenbaoSecretStore::new()` is `async` and **requires a running OpenBao** at construction time (it validates the token if using cached auth). If OpenBao is unreachable during construction, the call will fail. The graceful fallback only applies to `StoreSource::get()` calls after construction -- the `ConfigClient` must be built with a live store, or the store must be wrapped in a lazy initialization pattern.
```rust ```rust
// harmony_config/examples/openbao_chain.rs // harmony_config/examples/openbao_chain.rs
use harmony_config::{ConfigManager, EnvSource, SqliteSource, StoreSource}; use harmony_config::{ConfigClient, EnvSource, SqliteSource, StoreSource};
use harmony_secret::OpenbaoSecretStore; use harmony_secret::OpenbaoSecretStore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
@@ -517,7 +517,7 @@ async fn main() -> anyhow::Result<()> {
vec![env_source, sqlite] vec![env_source, sqlite]
}; };
let manager = ConfigManager::new(sources); let manager = ConfigClient::new(sources);
// Scenario 1: get() with nothing stored -- returns NotFound // Scenario 1: get() with nothing stored -- returns NotFound
let result = manager.get::<AppConfig>().await; let result = manager.get::<AppConfig>().await;
@@ -620,4 +620,4 @@ match self.store.get_raw(&self.namespace, key).await {
6. **Graceful fallback**: `StoreSource::get()` returns `Ok(None)` on any error (connection refused, timeout, etc.), allowing the chain to fall through to the next source. This ensures OpenBao unavailability doesn't break the config chain. 6. **Graceful fallback**: `StoreSource::get()` returns `Ok(None)` on any error (connection refused, timeout, etc.), allowing the chain to fall through to the next source. This ensures OpenBao unavailability doesn't break the config chain.
7. **StoreSource errors don't block chain**: When OpenBao is unreachable, `StoreSource::get()` returns `Ok(None)` and the `ConfigManager` continues to the next source (typically `SqliteSource`). This is validated by `test_store_source_error_falls_through_to_sqlite` and `test_store_source_not_found_falls_through_to_sqlite`. 7. **StoreSource errors don't block chain**: When OpenBao is unreachable, `StoreSource::get()` returns `Ok(None)` and the `ConfigClient` continues to the next source (typically `SqliteSource`). This is validated by `test_store_source_error_falls_through_to_sqlite` and `test_store_source_not_found_falls_through_to_sqlite`.

View File

@@ -211,11 +211,13 @@ The `bound_audiences` claim ties the role to the specific Harmony Zitadel applic
For organizations running their own infrastructure, the same architecture applies. The operator deploys Zitadel and OpenBao using Harmony's existing `ZitadelScore` and `OpenbaoScore`. The only configuration needed is three environment variables (or their equivalents in the bootstrap config): For organizations running their own infrastructure, the same architecture applies. The operator deploys Zitadel and OpenBao using Harmony's existing `ZitadelScore` and `OpenbaoScore`. The only configuration needed is three environment variables (or their equivalents in the bootstrap config):
- `HARMONY_SSO_URL` — the Zitadel instance URL. - `HARMONY_SSO_URL` — the Zitadel instance URL.
- `HARMONY_SECRETS_URL` — the OpenBao instance URL. - `OPENBAO_URL` (or `VAULT_ADDR`) — the OpenBao instance URL.
- `HARMONY_SSO_CLIENT_ID` — the Zitadel application client ID. - `HARMONY_SSO_CLIENT_ID` — the Zitadel application client ID.
None of these are secrets. They can be committed to an infrastructure repository or distributed via any convenient channel. None of these are secrets. They can be committed to an infrastructure repository or distributed via any convenient channel.
`OPENBAO_URL`/`VAULT_ADDR` is named after the backend on purpose: it is read only by the OpenBao adapter, never by domain or Score code, and reusing the standard Vault/OpenBao variable names gives operators interop with existing tooling. The tool-agnostic seam lives one layer up — `HARMONY_SECRET_STORE` selects which backend is active, and each backend then reads its own connection params (e.g. `HARMONY_SECRET_INFISICAL_*`). An earlier draft of this ADR proposed a single agnostic `HARMONY_SECRETS_URL`, but it was never implemented: different stores don't share a connection shape (Infisical needs a project and client credentials; AWS Secrets Manager has no URL at all), so one "secrets URL" would force OpenBao's model onto every backend.
## Consequences ## Consequences
### Positive ### Positive

View File

@@ -10,7 +10,7 @@ use harmony::modules::zitadel::{
}; };
use harmony::score::Score; use harmony::score::Score;
use harmony::topology::{K8sclient, Topology}; use harmony::topology::{K8sclient, Topology};
use harmony_config::{Config, ConfigManager, EnvSource, StoreSource}; use harmony_config::{Config, ConfigClient, EnvSource, StoreSource};
use harmony_k8s::K8sClient; use harmony_k8s::K8sClient;
use harmony_secret::OpenbaoSecretStore; use harmony_secret::OpenbaoSecretStore;
use k3d_rs::{K3d, PortMapping}; use k3d_rs::{K3d, PortMapping};
@@ -380,7 +380,7 @@ async fn main() -> anyhow::Result<()> {
.await .await
.context("SSO authentication failed")?; .context("SSO authentication failed")?;
let manager = ConfigManager::new(vec![ let manager = ConfigClient::new(vec![
Arc::new(EnvSource) as Arc<dyn harmony_config::ConfigSource>, Arc::new(EnvSource) as Arc<dyn harmony_config::ConfigSource>,
Arc::new(StoreSource::new("harmony".to_string(), store)), Arc::new(StoreSource::new("harmony".to_string(), store)),
]); ]);

View File

@@ -62,7 +62,7 @@ impl FirewallPairTopology {
pub async fn opnsense_from_config() -> Self { pub async fn opnsense_from_config() -> Self {
// TODO: both firewalls share the same credentials. Once named config // TODO: both firewalls share the same credentials. Once named config
// instances are available (ROADMAP/11), use per-device credentials: // instances are available (ROADMAP/11), use per-device credentials:
// ConfigManager::get_named::<OPNSenseApiCredentials>("fw-primary") // ConfigClient::get_named::<OPNSenseApiCredentials>("fw-primary")
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>() let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
.await .await
.expect("Failed to get SSH credentials"); .expect("Failed to get SSH credentials");

View File

@@ -12,7 +12,7 @@
use std::sync::Arc; use std::sync::Arc;
use harmony_config::{Config, ConfigManager, EnvSource, SqliteSource}; use harmony_config::{Config, ConfigClient, EnvSource, SqliteSource};
use log::info; use log::info;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -36,8 +36,10 @@ impl Default for TestConfig {
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
env_logger::init(); env_logger::init();
let sqlite = SqliteSource::default().await?; // Namespace the SQLite file so this example's state doesn't
let manager = ConfigManager::new(vec![Arc::new(EnvSource), Arc::new(sqlite)]); // collide with other harmony binaries that also use SqliteSource.
let sqlite = SqliteSource::for_namespace("harmony_config-basic-example").await?;
let manager = ConfigClient::new(vec![Arc::new(EnvSource), Arc::new(sqlite)]);
info!("1. Attempting to get TestConfig (expect NotFound on first run)..."); info!("1. Attempting to get TestConfig (expect NotFound on first run)...");
match manager.get::<TestConfig>().await { match manager.get::<TestConfig>().await {

View File

@@ -1,60 +1,24 @@
//! End-to-end example: harmony_config with OpenBao as a ConfigSource //! Dev-binary template: `ConfigClient` against an OpenBao-backed chain.
//! //!
//! This example demonstrates the full config resolution chain: //! Bring up the OpenBao + Zitadel stack first (`cargo run -p
//! EnvSource → SqliteSource → StoreSource<OpenbaoSecretStore> //! example-harmony-sso`), then export the connection env vars and run this:
//! //!
//! When OpenBao is unreachable, the chain gracefully falls through to SQLite.
//!
//! **Prerequisites**:
//! - OpenBao must be initialized and unsealed
//! - KV v2 engine must be enabled at the `OPENBAO_KV_MOUNT` path (default: `secret`)
//! - Auth method must be enabled at the `OPENBAO_AUTH_MOUNT` path (default: `userpass`)
//!
//! **Environment variables**:
//! - `OPENBAO_URL` (required for OpenBao): URL of the OpenBao server
//! - `OPENBAO_TOKEN` (optional): Use token auth instead of userpass
//! - `OPENBAO_USERNAME` + `OPENBAO_PASSWORD` (optional): Userpass auth
//! - `OPENBAO_KV_MOUNT` (default: `secret`): KV v2 engine mount path
//! - `OPENBAO_AUTH_MOUNT` (default: `userpass`): Auth method mount path
//! - `OPENBAO_SKIP_TLS` (default: `false`): Skip TLS verification
//! - `HARMONY_SSO_URL` + `HARMONY_SSO_CLIENT_ID` (optional): Zitadel OIDC device flow (RFC 8628)
//!
//! **Run**:
//! ```bash //! ```bash
//! # Without OpenBao (SqliteSource only): //! export OPENBAO_URL=http://bao.harmony.local:8080
//! cargo run --example openbao_chain //! export HARMONY_SSO_URL=http://sso.harmony.local:8080
//! //! export HARMONY_SSO_CLIENT_ID=<the harmony-cli client id> # or OPENBAO_TOKEN=<token>
//! # With OpenBao (full chain): //! cargo run -p harmony_config --example openbao_chain
//! export OPENBAO_URL="http://127.0.0.1:8200"
//! export OPENBAO_TOKEN="<your-token>"
//! cargo run --example openbao_chain
//! ``` //! ```
//! //!
//! **Setup OpenBao** (if needed): //! If OpenBao is unreachable the chain degrades to env → prompt, so the
//! ```bash //! round-trip steps below need a reachable OpenBao to persist.
//! # Port-forward to local OpenBao
//! kubectl port-forward svc/openbao -n openbao 8200:8200 &
//!
//! # Initialize (one-time)
//! kubectl exec -n openbao openbao-0 -- bao operator init
//!
//! # Enable KV and userpass (one-time)
//! kubectl exec -n openbao openbao-0 -- bao secrets enable -path=secret kv-v2
//! kubectl exec -n openbao openbao-0 -- bao auth enable userpass
//!
//! # Create test user
//! kubectl exec -n openbao openbao-0 -- bao write auth/userpass/users/testuser \
//! password="testpass" policies="default"
//! ```
use std::sync::Arc; use harmony_config::{Config, ConfigClient, ConfigExt};
use log::info;
use harmony_config::{Config, ConfigManager, ConfigSource, EnvSource, SqliteSource, StoreSource};
use harmony_secret::OpenbaoSecretStore;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct AppConfig { struct AppConfig {
host: String, host: String,
port: u16, port: u16,
@@ -63,130 +27,70 @@ struct AppConfig {
impl Default for AppConfig { impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
host: "localhost".to_string(), host: "production.example.com".to_string(),
port: 8080, port: 443,
} }
} }
} }
impl Config for AppConfig { // `password` is `#[config(secret)]`, so the whole struct is Secret-class:
const KEY: &'static str = "AppConfig"; // masked in logs and prompted via `inquire::Password`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct DatabaseCredentials {
host: String,
username: String,
#[config(secret)]
password: String,
} }
async fn build_manager() -> ConfigManager { impl Default for DatabaseCredentials {
let sqlite = Arc::new( fn default() -> Self {
SqliteSource::default() Self {
.await host: "db.example.com".to_string(),
.expect("Failed to open SQLite database"), username: "app_rw".to_string(),
); password: "rotate-me-please".to_string(),
}
}
}
let env_source: Arc<dyn ConfigSource> = Arc::new(EnvSource); // No `Default`, so `get_or_prompt` falls through to an interactive prompt.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct ApiCredentials {
client_id: String,
#[config(secret)]
client_secret: String,
}
let openbao_url = std::env::var("OPENBAO_URL") // Read `T`, or store `default` and read it back to prove the round-trip.
.or_else(|_| std::env::var("VAULT_ADDR")) async fn round_trip<T>(client: &ConfigClient, default: T) -> anyhow::Result<()>
.ok(); where
T: Config + std::fmt::Debug + Clone + PartialEq,
match openbao_url {
Some(url) => {
let kv_mount =
std::env::var("OPENBAO_KV_MOUNT").unwrap_or_else(|_| "secret".to_string());
let auth_mount =
std::env::var("OPENBAO_AUTH_MOUNT").unwrap_or_else(|_| "userpass".to_string());
let skip_tls = std::env::var("OPENBAO_SKIP_TLS")
.map(|v| v == "true")
.unwrap_or(false);
match OpenbaoSecretStore::new(
url,
kv_mount,
auth_mount,
skip_tls,
std::env::var("OPENBAO_TOKEN").ok(),
std::env::var("OPENBAO_USERNAME").ok(),
std::env::var("OPENBAO_PASSWORD").ok(),
std::env::var("HARMONY_SSO_URL").ok(),
std::env::var("HARMONY_SSO_CLIENT_ID").ok(),
None,
None,
)
.await
{ {
Ok(store) => { match client.get::<T>().await {
let store_source: Arc<dyn ConfigSource> = Ok(found) => info!("[{}] read existing: {:?}", T::KEY, found.masked()),
Arc::new(StoreSource::new("harmony".to_string(), store)); Err(harmony_config::ConfigError::NotFound { .. }) => {
println!("OpenBao connected. Full chain: env → sqlite → openbao"); client.set(&default).await?;
ConfigManager::new(vec![env_source, Arc::clone(&sqlite) as _, store_source]) let back: T = client.get().await?;
} anyhow::ensure!(back == default, "round-trip mismatch for {}", T::KEY);
Err(e) => { info!("[{}] stored + verified: {:?}", T::KEY, back.masked());
eprintln!(
"Warning: OpenBao unavailable ({e}), using local chain: env → sqlite"
);
ConfigManager::new(vec![env_source, sqlite])
}
}
}
None => {
println!("OPENBAO_URL not set. Using local chain: env → sqlite");
ConfigManager::new(vec![env_source, sqlite])
} }
Err(e) => return Err(e.into()),
} }
Ok(())
} }
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
env_logger::init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let manager = build_manager().await; let client = ConfigClient::for_namespace("harmony").await;
println!("\n=== harmony_config OpenBao Chain Demo ===\n"); round_trip(&client, AppConfig::default()).await?;
round_trip(&client, DatabaseCredentials::default()).await?;
println!("1. Attempting to get AppConfig (expect NotFound on first run)..."); // No default + Secret class: prompts, with `client_secret` read via Password.
match manager.get::<AppConfig>().await { let api: ApiCredentials = client.get_or_prompt().await?;
Ok(config) => { info!("[{}] resolved: {:?}", ApiCredentials::KEY, api.masked());
println!(" Found: {:?}", config);
}
Err(harmony_config::ConfigError::NotFound { .. }) => {
println!(" NotFound - no config stored yet");
}
Err(e) => {
println!(" Error: {:?}", e);
}
}
println!("\n2. Setting AppConfig via set()...");
let config = AppConfig {
host: "production.example.com".to_string(),
port: 443,
};
manager.set(&config).await?;
println!(" Set: {:?}", config);
println!("\n3. Getting AppConfig back...");
let retrieved: AppConfig = manager.get().await?;
println!(" Retrieved: {:?}", retrieved);
assert_eq!(config, retrieved);
println!("\n4. Demonstrating env override...");
println!(" HARMONY_CONFIG_AppConfig env var overrides all backends");
let env_config = AppConfig {
host: "env-override.example.com".to_string(),
port: 9090,
};
unsafe {
std::env::set_var(
"HARMONY_CONFIG_AppConfig",
serde_json::to_string(&env_config)?,
);
}
let from_env: AppConfig = manager.get().await?;
println!(" Got from env: {:?}", from_env);
assert_eq!(env_config.host, "env-override.example.com");
unsafe {
std::env::remove_var("HARMONY_CONFIG_AppConfig");
}
println!("\n=== Done! ===");
println!("Config persisted at ~/.local/share/harmony/config/config.db");
Ok(()) Ok(())
} }

View File

@@ -12,7 +12,7 @@
use std::sync::Arc; use std::sync::Arc;
use harmony_config::{Config, ConfigManager, EnvSource, PromptSource, SqliteSource}; use harmony_config::{Config, ConfigClient, EnvSource, PromptSource, SqliteSource};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -27,8 +27,10 @@ struct UserConfig {
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
env_logger::init(); env_logger::init();
let sqlite = SqliteSource::default().await?; // Namespace the SQLite file so this example's state doesn't
let manager = ConfigManager::new(vec![ // collide with other harmony binaries that also use SqliteSource.
let sqlite = SqliteSource::for_namespace("harmony_config-prompting-example").await?;
let manager = ConfigClient::new(vec![
Arc::new(EnvSource), Arc::new(EnvSource),
Arc::new(sqlite), Arc::new(sqlite),
Arc::new(PromptSource::new()), Arc::new(PromptSource::new()),

View File

@@ -1,9 +1,13 @@
// Lets the derive macro emit `::harmony_config::…` paths that resolve both
// inside this crate and in downstream consumers.
extern crate self as harmony_config;
mod source; mod source;
use async_trait::async_trait; use async_trait::async_trait;
use directories::ProjectDirs; use directories::ProjectDirs;
use interactive_parse::InteractiveParseObj; use interactive_parse::InteractiveParseObj;
use log::debug; use log::warn;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use std::path::PathBuf; use std::path::PathBuf;
@@ -57,40 +61,107 @@ pub enum ConfigError {
SqliteError(String), SqliteError(String),
} }
/// Hint passed through the `ConfigSource` chain; backends that care
/// (masking, prompting) inspect it, others ignore it. Elevated to `Secret`
/// automatically when any field — or the struct — is `#[config(secret)]`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfigClass {
Standard,
Secret,
}
pub trait Config: Serialize + DeserializeOwned + JsonSchema + InteractiveParseObj + Sized { pub trait Config: Serialize + DeserializeOwned + JsonSchema + InteractiveParseObj + Sized {
const KEY: &'static str; const KEY: &'static str;
const CLASS: ConfigClass = ConfigClass::Standard;
// Serialized names of `#[config(secret)]` fields, used for masking and
// password prompts. Matching is on the serialized name, so a secret
// field must not be `#[serde(rename)]`d — the derive records the Rust
// ident, and a rename would leak the value in cleartext.
const SECRET_FIELDS: &'static [&'static str] = &[];
} }
/// Safe-display wrapper: `{:?}` renders `T::SECRET_FIELDS` as `"****"`.
/// Use it on any `Config` value headed for a log line or error message.
pub struct Masked<'a, T: Config>(&'a T);
impl<T: Config> std::fmt::Debug for Masked<'_, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut value = serde_json::to_value(self.0).map_err(|_| std::fmt::Error)?;
if let serde_json::Value::Object(map) = &mut value {
for name in T::SECRET_FIELDS {
if let Some(v) = map.get_mut(*name) {
*v = serde_json::Value::String("****".to_string());
}
}
}
write!(f, "{value:#}")
}
}
pub trait ConfigExt: Config {
fn masked(&self) -> Masked<'_, Self> {
Masked(self)
}
}
impl<T: Config> ConfigExt for T {}
#[async_trait] #[async_trait]
pub trait ConfigSource: Send + Sync { pub trait ConfigSource: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>; // `Ok(None)` means "absent here, try the next source"; `Err`
// short-circuits the chain. `class` is a hint most sources ignore.
async fn get(
&self,
class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError>;
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>; async fn set(
&self,
fn should_persist(&self) -> bool { class: ConfigClass,
true key: &str,
} value: &serde_json::Value,
) -> Result<(), ConfigError>;
} }
pub struct ConfigManager { pub struct ConfigClient {
sources: Vec<Arc<dyn ConfigSource>>, sources: Vec<Arc<dyn ConfigSource>>,
} }
impl ConfigManager { impl ConfigClient {
pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self { pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self {
Self { sources } Self { sources }
} }
/// Canonical chain `EnvSource → OpenBao → PromptSource`, scoped to
/// `namespace`. OpenBao is configured from env vars (see
/// [`openbao_from_env`]) and dropped from the chain if unset or
/// unreachable. SQLite is excluded on purpose: it stores cleartext on
/// disk and can't safely hold `Secret`-class config. For other chains,
/// build the sources yourself and pass them to [`ConfigClient::new`].
pub async fn for_namespace(namespace: &str) -> Self {
let mut sources: Vec<Arc<dyn ConfigSource>> = vec![Arc::new(EnvSource)];
if let Some(store) = openbao_from_env(namespace).await {
sources.push(store);
}
sources.push(Arc::new(PromptSource::new()));
Self::new(sources)
}
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> { pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
for source in &self.sources { for source in &self.sources {
if let Some(value) = source.get(T::KEY).await? { if let Some(value) = source.get(T::CLASS, T::KEY).await? {
let config: T = // A deser failure means the stored value is shaped for a
serde_json::from_value(value).map_err(|e| ConfigError::Deserialization { // different version of the struct (branch switch, rename);
key: T::KEY.to_string(), // treat it as a miss for this source and fall through so a
source: e, // later source — or a re-prompt — overwrites the stale entry.
})?; match serde_json::from_value::<T>(value) {
debug!("Retrieved config for key {} from source", T::KEY); Ok(config) => return Ok(config),
return Ok(config); Err(e) => warn!(
"Stale value for key {} in source; falling through ({e})",
T::KEY
),
}
} }
} }
Err(ConfigError::NotFound { Err(ConfigError::NotFound {
@@ -102,24 +173,8 @@ impl ConfigManager {
match self.get::<T>().await { match self.get::<T>().await {
Ok(config) => Ok(config), Ok(config) => Ok(config),
Err(ConfigError::NotFound { .. }) => { Err(ConfigError::NotFound { .. }) => {
let config = let config = PromptSource::new().prompt_for::<T>().await?;
T::parse_to_obj().map_err(|e| ConfigError::PromptError(e.to_string()))?; self.set(&config).await?;
let value =
serde_json::to_value(&config).map_err(|e| ConfigError::Serialization {
key: T::KEY.to_string(),
source: e,
})?;
for source in &self.sources {
if !source.should_persist() {
continue;
}
if source.set(T::KEY, &value).await.is_ok() {
break;
}
}
Ok(config) Ok(config)
} }
Err(e) => Err(e), Err(e) => Err(e),
@@ -133,22 +188,61 @@ impl ConfigManager {
})?; })?;
for source in &self.sources { for source in &self.sources {
source.set(T::KEY, &value).await?; source.set(T::CLASS, T::KEY, &value).await?;
} }
Ok(()) Ok(())
} }
} }
static CONFIG_MANAGER: Mutex<Option<Arc<ConfigManager>>> = Mutex::const_new(None); /// Build an OpenBao-backed `StoreSource` from env vars, or `None` (with a
/// `warn!`) when `OPENBAO_URL`/`VAULT_ADDR` is unset or the connection
/// fails — so a missing OpenBao degrades the chain instead of breaking
/// startup. `jwt_role`/`jwt_auth_mount` are left to `OpenbaoSecretStore`'s
/// defaults, matching what `OpenbaoSetupScore` configures.
async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
let Some(url) = std::env::var("OPENBAO_URL")
.or_else(|_| std::env::var("VAULT_ADDR"))
.ok()
else {
warn!("OpenBao URL not set; OpenBao source omitted from chain");
return None;
};
let env = |k: &str| std::env::var(k).ok();
let store = harmony_secret::OpenbaoSecretStore::new(
url,
env("OPENBAO_KV_MOUNT").unwrap_or_else(|| "secret".to_string()),
env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()),
env("OPENBAO_SKIP_TLS").as_deref() == Some("true"),
env("OPENBAO_TOKEN"),
env("OPENBAO_USERNAME"),
env("OPENBAO_PASSWORD"),
env("HARMONY_SSO_URL"),
env("HARMONY_SSO_CLIENT_ID"),
None,
None,
)
.await;
match store {
Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))),
Err(e) => {
warn!("OpenBao unreachable ({e}); source omitted from chain");
None
}
}
}
static CONFIG_CLIENT: Mutex<Option<Arc<ConfigClient>>> = Mutex::const_new(None);
pub async fn init(sources: Vec<Arc<dyn ConfigSource>>) { pub async fn init(sources: Vec<Arc<dyn ConfigSource>>) {
let mut manager = CONFIG_MANAGER.lock().await; let mut manager = CONFIG_CLIENT.lock().await;
*manager = Some(Arc::new(ConfigManager::new(sources))); *manager = Some(Arc::new(ConfigClient::new(sources)));
} }
pub async fn get<T: Config>() -> Result<T, ConfigError> { pub async fn get<T: Config>() -> Result<T, ConfigError> {
let manager = CONFIG_MANAGER.lock().await; let manager = CONFIG_CLIENT.lock().await;
manager manager
.as_ref() .as_ref()
.ok_or(ConfigError::NoSources)? .ok_or(ConfigError::NoSources)?
@@ -157,7 +251,7 @@ pub async fn get<T: Config>() -> Result<T, ConfigError> {
} }
pub async fn get_or_prompt<T: Config>() -> Result<T, ConfigError> { pub async fn get_or_prompt<T: Config>() -> Result<T, ConfigError> {
let manager = CONFIG_MANAGER.lock().await; let manager = CONFIG_CLIENT.lock().await;
manager manager
.as_ref() .as_ref()
.ok_or(ConfigError::NoSources)? .ok_or(ConfigError::NoSources)?
@@ -166,7 +260,7 @@ pub async fn get_or_prompt<T: Config>() -> Result<T, ConfigError> {
} }
pub async fn set<T: Config>(config: &T) -> Result<(), ConfigError> { pub async fn set<T: Config>(config: &T) -> Result<(), ConfigError> {
let manager = CONFIG_MANAGER.lock().await; let manager = CONFIG_CLIENT.lock().await;
manager manager
.as_ref() .as_ref()
.ok_or(ConfigError::NoSources)? .ok_or(ConfigError::NoSources)?
@@ -229,6 +323,10 @@ mod tests {
data: std::sync::Mutex<std::collections::HashMap<String, serde_json::Value>>, data: std::sync::Mutex<std::collections::HashMap<String, serde_json::Value>>,
get_count: AtomicUsize, get_count: AtomicUsize,
set_count: AtomicUsize, set_count: AtomicUsize,
/// Records each `(class, key)` pair observed on `get` / `set`
/// so tests can assert that `ConfigClient` forwards `T::CLASS`
/// to sources unchanged.
observed: std::sync::Mutex<Vec<(ConfigClass, String, &'static str)>>,
} }
impl MockSource { impl MockSource {
@@ -237,6 +335,7 @@ mod tests {
data: std::sync::Mutex::new(std::collections::HashMap::new()), data: std::sync::Mutex::new(std::collections::HashMap::new()),
get_count: AtomicUsize::new(0), get_count: AtomicUsize::new(0),
set_count: AtomicUsize::new(0), set_count: AtomicUsize::new(0),
observed: std::sync::Mutex::new(Vec::new()),
} }
} }
@@ -245,6 +344,7 @@ mod tests {
data: std::sync::Mutex::new(data), data: std::sync::Mutex::new(data),
get_count: AtomicUsize::new(0), get_count: AtomicUsize::new(0),
set_count: AtomicUsize::new(0), set_count: AtomicUsize::new(0),
observed: std::sync::Mutex::new(Vec::new()),
} }
} }
@@ -255,18 +355,39 @@ mod tests {
fn set_call_count(&self) -> usize { fn set_call_count(&self) -> usize {
self.set_count.load(Ordering::SeqCst) self.set_count.load(Ordering::SeqCst)
} }
fn observed(&self) -> Vec<(ConfigClass, String, &'static str)> {
self.observed.lock().unwrap().clone()
}
} }
#[async_trait] #[async_trait]
impl ConfigSource for MockSource { impl ConfigSource for MockSource {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> { async fn get(
&self,
class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
self.get_count.fetch_add(1, Ordering::SeqCst); self.get_count.fetch_add(1, Ordering::SeqCst);
self.observed
.lock()
.unwrap()
.push((class, key.to_string(), "get"));
let data = self.data.lock().unwrap(); let data = self.data.lock().unwrap();
Ok(data.get(key).cloned()) Ok(data.get(key).cloned())
} }
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> { async fn set(
&self,
class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
self.set_count.fetch_add(1, Ordering::SeqCst); self.set_count.fetch_add(1, Ordering::SeqCst);
self.observed
.lock()
.unwrap()
.push((class, key.to_string(), "set"));
let mut data = self.data.lock().unwrap(); let mut data = self.data.lock().unwrap();
data.insert(key.to_string(), value.clone()); data.insert(key.to_string(), value.clone());
Ok(()) Ok(())
@@ -286,7 +407,7 @@ mod tests {
); );
let source = Arc::new(MockSource::with_data(data)); let source = Arc::new(MockSource::with_data(data));
let manager = ConfigManager::new(vec![source.clone()]); let manager = ConfigClient::new(vec![source.clone()]);
let result: TestConfig = manager.get().await.unwrap(); let result: TestConfig = manager.get().await.unwrap();
assert_eq!(result, config); assert_eq!(result, config);
@@ -307,7 +428,7 @@ mod tests {
let source1 = Arc::new(MockSource::new()); let source1 = Arc::new(MockSource::new());
let source2 = Arc::new(MockSource::with_data(data2)); let source2 = Arc::new(MockSource::with_data(data2));
let manager = ConfigManager::new(vec![source1.clone(), source2.clone()]); let manager = ConfigClient::new(vec![source1.clone(), source2.clone()]);
let result: TestConfig = manager.get().await.unwrap(); let result: TestConfig = manager.get().await.unwrap();
assert_eq!(result, config); assert_eq!(result, config);
@@ -318,7 +439,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_get_returns_not_found_when_no_source_has_key() { async fn test_get_returns_not_found_when_no_source_has_key() {
let source = Arc::new(MockSource::new()); let source = Arc::new(MockSource::new());
let manager = ConfigManager::new(vec![source.clone()]); let manager = ConfigClient::new(vec![source.clone()]);
let result: Result<TestConfig, ConfigError> = manager.get().await; let result: Result<TestConfig, ConfigError> = manager.get().await;
assert!(matches!(result, Err(ConfigError::NotFound { .. }))); assert!(matches!(result, Err(ConfigError::NotFound { .. })));
@@ -326,7 +447,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_get_returns_error_with_no_sources() { async fn test_get_returns_error_with_no_sources() {
let manager = ConfigManager::new(vec![]); let manager = ConfigClient::new(vec![]);
let result: Result<TestConfig, ConfigError> = manager.get().await; let result: Result<TestConfig, ConfigError> = manager.get().await;
assert!(matches!(result, Err(ConfigError::NotFound { .. }))); assert!(matches!(result, Err(ConfigError::NotFound { .. })));
@@ -341,7 +462,7 @@ mod tests {
let source1 = Arc::new(MockSource::new()); let source1 = Arc::new(MockSource::new());
let source2 = Arc::new(MockSource::new()); let source2 = Arc::new(MockSource::new());
let manager = ConfigManager::new(vec![source1.clone(), source2.clone()]); let manager = ConfigClient::new(vec![source1.clone(), source2.clone()]);
manager.set(&config).await.unwrap(); manager.set(&config).await.unwrap();
@@ -352,6 +473,228 @@ mod tests {
assert_eq!(result1, config); assert_eq!(result1, config);
} }
#[tokio::test]
async fn test_derive_macro_emits_standard_class_with_no_secret_fields() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct PlainConfig {
host: String,
port: u16,
}
assert_eq!(PlainConfig::KEY, "PlainConfig");
assert_eq!(PlainConfig::CLASS, ConfigClass::Standard);
}
#[tokio::test]
async fn test_derive_macro_emits_secret_class_when_field_is_tagged() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct WithSecretField {
host: String,
#[config(secret)]
password: String,
}
assert_eq!(WithSecretField::CLASS, ConfigClass::Secret);
}
#[tokio::test]
async fn test_derive_macro_emits_secret_class_when_struct_is_tagged() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
#[config(secret)]
struct AllSecret {
token: String,
}
assert_eq!(AllSecret::CLASS, ConfigClass::Secret);
}
// -- SECRET_FIELDS derivation -----------------------------------------
#[tokio::test]
async fn test_derive_emits_empty_secret_fields_for_standard_struct() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct StandardPlain {
host: String,
port: u16,
}
assert_eq!(StandardPlain::CLASS, ConfigClass::Standard);
assert_eq!(StandardPlain::SECRET_FIELDS, &[] as &[&str]);
}
#[tokio::test]
async fn test_derive_emits_tagged_fields_for_per_field_secret() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct MixedSecret {
host: String,
#[config(secret)]
password: String,
#[config(secret)]
api_key: String,
non_secret: u16,
}
assert_eq!(MixedSecret::CLASS, ConfigClass::Secret);
// Names emitted in struct-field order; only the tagged ones.
assert_eq!(MixedSecret::SECRET_FIELDS, &["password", "api_key"]);
}
#[tokio::test]
async fn test_derive_emits_all_fields_for_struct_level_secret() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
#[config(secret)]
struct WholeStructSecret {
token: String,
refresh: String,
count: u32,
}
assert_eq!(WholeStructSecret::CLASS, ConfigClass::Secret);
assert_eq!(
WholeStructSecret::SECRET_FIELDS,
&["token", "refresh", "count"]
);
}
// -- Masked wrapper / ConfigExt::masked() -----------------------------
#[tokio::test]
async fn test_masked_replaces_secret_fields_with_stars() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct Mixed {
host: String,
#[config(secret)]
password: String,
}
let v = Mixed {
host: "db.example.com".to_string(),
password: "hunter2".to_string(),
};
let s = format!("{:?}", v.masked());
assert!(s.contains("db.example.com"), "host should be visible: {s}");
assert!(
s.contains("\"****\""),
"password should render as ****: {s}"
);
assert!(!s.contains("hunter2"), "raw password must not appear: {s}");
}
#[tokio::test]
async fn test_masked_passes_standard_struct_through_unmodified() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct PlainStandard {
name: String,
count: u32,
}
let v = PlainStandard {
name: "alice".to_string(),
count: 7,
};
let s = format!("{:?}", v.masked());
assert!(s.contains("alice"));
assert!(s.contains('7'));
assert!(!s.contains("****"));
}
#[tokio::test]
async fn test_masked_masks_every_field_for_struct_level_secret() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
#[config(secret)]
struct AllSecret {
token: String,
refresh: String,
}
let v = AllSecret {
token: "tok123".to_string(),
refresh: "ref456".to_string(),
};
let s = format!("{:?}", v.masked());
assert!(!s.contains("tok123"), "token must be masked: {s}");
assert!(!s.contains("ref456"), "refresh must be masked: {s}");
// Both fields should render as "****".
assert_eq!(s.matches("\"****\"").count(), 2, "got: {s}");
}
#[tokio::test]
async fn test_manual_impl_defaults_to_standard_class() {
// The trait gives CLASS a default of Standard so existing manual
// impls (like TestConfig above) compile without explicit updates.
assert_eq!(TestConfig::CLASS, ConfigClass::Standard);
}
#[tokio::test]
async fn test_config_manager_forwards_standard_class_to_sources_on_set() {
let source = Arc::new(MockSource::new());
let manager = ConfigClient::new(vec![source.clone()]);
let config = TestConfig {
name: "x".into(),
count: 1,
};
manager.set(&config).await.unwrap();
let observed = source.observed();
assert_eq!(observed.len(), 1);
let (class, key, op) = &observed[0];
assert_eq!(*class, ConfigClass::Standard);
assert_eq!(key, "TestConfig");
assert_eq!(*op, "set");
}
#[tokio::test]
async fn test_config_manager_forwards_secret_class_to_sources_on_set() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct SecretConfig {
#[config(secret)]
password: String,
}
assert_eq!(SecretConfig::CLASS, ConfigClass::Secret);
let source = Arc::new(MockSource::new());
let manager = ConfigClient::new(vec![source.clone()]);
manager
.set(&SecretConfig {
password: "hunter2".into(),
})
.await
.unwrap();
let (class, key, op) = source.observed().pop().unwrap();
assert_eq!(class, ConfigClass::Secret);
assert_eq!(key, "SecretConfig");
assert_eq!(op, "set");
}
#[tokio::test]
async fn test_config_manager_forwards_class_to_every_source_on_get() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
#[config(secret)]
struct StructLevelSecret {
token: String,
}
// Two empty sources so the chain probes both before returning NotFound.
let s1 = Arc::new(MockSource::new());
let s2 = Arc::new(MockSource::new());
let manager = ConfigClient::new(vec![s1.clone(), s2.clone()]);
let res: Result<StructLevelSecret, _> = manager.get().await;
assert!(matches!(res, Err(ConfigError::NotFound { .. })));
for src in [&s1, &s2] {
let observed = src.observed();
assert_eq!(observed.len(), 1);
let (class, key, op) = &observed[0];
assert_eq!(*class, ConfigClass::Secret);
assert_eq!(key, "StructLevelSecret");
assert_eq!(*op, "get");
}
}
#[tokio::test] #[tokio::test]
async fn test_derive_macro_generates_correct_key() { async fn test_derive_macro_generates_correct_key() {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -372,7 +715,12 @@ mod tests {
let env_var = setup_env_vars("TestConfig", Some(r#"{"name":"from_env","count":7}"#)); let env_var = setup_env_vars("TestConfig", Some(r#"{"name":"from_env","count":7}"#));
let source = EnvSource; let source = EnvSource;
let result = source.get(&env_var.replace("HARMONY_CONFIG_", "")).await; let result = source
.get(
ConfigClass::Standard,
&env_var.replace("HARMONY_CONFIG_", ""),
)
.await;
unsafe { unsafe {
std::env::remove_var(&env_var); std::env::remove_var(&env_var);
@@ -396,7 +744,12 @@ mod tests {
.unwrap(); .unwrap();
rt.block_on(async { rt.block_on(async {
let source = EnvSource; let source = EnvSource;
let result = source.get(&env_var.replace("HARMONY_CONFIG_", "")).await; let result = source
.get(
ConfigClass::Standard,
&env_var.replace("HARMONY_CONFIG_", ""),
)
.await;
assert!(result.unwrap().is_none()); assert!(result.unwrap().is_none());
}); });
}); });
@@ -408,7 +761,12 @@ mod tests {
let env_var = setup_env_vars("TestConfig", Some("not valid json")); let env_var = setup_env_vars("TestConfig", Some("not valid json"));
let source = EnvSource; let source = EnvSource;
let result = source.get(&env_var.replace("HARMONY_CONFIG_", "")).await; let result = source
.get(
ConfigClass::Standard,
&env_var.replace("HARMONY_CONFIG_", ""),
)
.await;
unsafe { unsafe {
std::env::remove_var(&env_var); std::env::remove_var(&env_var);
@@ -430,7 +788,11 @@ mod tests {
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap(); std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let source = LocalFileSource::new(dir.path().to_path_buf()); let source = LocalFileSource::new(dir.path().to_path_buf());
let result = source.get("TestConfig").await.unwrap().unwrap(); let result = source
.get(ConfigClass::Standard, "TestConfig")
.await
.unwrap()
.unwrap();
let parsed: TestConfig = serde_json::from_value(result).unwrap(); let parsed: TestConfig = serde_json::from_value(result).unwrap();
assert_eq!(parsed, config); assert_eq!(parsed, config);
@@ -442,7 +804,10 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let source = LocalFileSource::new(dir.path().to_path_buf()); let source = LocalFileSource::new(dir.path().to_path_buf());
let result = source.get("NonExistentConfig").await.unwrap(); let result = source
.get(ConfigClass::Standard, "NonExistentConfig")
.await
.unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
@@ -459,7 +824,11 @@ mod tests {
}; };
source source
.set("TestConfig", &serde_json::to_value(&config).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config).unwrap(),
)
.await .await
.unwrap(); .unwrap();
@@ -484,11 +853,19 @@ mod tests {
}; };
source source
.set("TestConfig", &serde_json::to_value(&config).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config).unwrap(),
)
.await .await
.unwrap(); .unwrap();
let result = source.get("TestConfig").await.unwrap().unwrap(); let result = source
.get(ConfigClass::Standard, "TestConfig")
.await
.unwrap()
.unwrap();
let parsed: TestConfig = serde_json::from_value(result).unwrap(); let parsed: TestConfig = serde_json::from_value(result).unwrap();
assert_eq!(parsed, config); assert_eq!(parsed, config);
@@ -502,7 +879,10 @@ mod tests {
let path = temp_file.path().to_path_buf(); let path = temp_file.path().to_path_buf();
let source = SqliteSource::open(path).await.unwrap(); let source = SqliteSource::open(path).await.unwrap();
let result = source.get("NonExistentConfig").await.unwrap(); let result = source
.get(ConfigClass::Standard, "NonExistentConfig")
.await
.unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
@@ -525,15 +905,27 @@ mod tests {
}; };
source source
.set("TestConfig", &serde_json::to_value(&config1).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config1).unwrap(),
)
.await .await
.unwrap(); .unwrap();
source source
.set("TestConfig", &serde_json::to_value(&config2).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config2).unwrap(),
)
.await .await
.unwrap(); .unwrap();
let result = source.get("TestConfig").await.unwrap().unwrap(); let result = source
.get(ConfigClass::Standard, "TestConfig")
.await
.unwrap()
.unwrap();
let parsed: TestConfig = serde_json::from_value(result).unwrap(); let parsed: TestConfig = serde_json::from_value(result).unwrap();
assert_eq!(parsed, config2); assert_eq!(parsed, config2);
@@ -561,17 +953,33 @@ mod tests {
let (r1, r2) = tokio::join!( let (r1, r2) = tokio::join!(
async { async {
source source
.set("key1", &serde_json::to_value(&config1).unwrap()) .set(
ConfigClass::Standard,
"key1",
&serde_json::to_value(&config1).unwrap(),
)
.await .await
.unwrap(); .unwrap();
source.get("key1").await.unwrap().unwrap() source
.get(ConfigClass::Standard, "key1")
.await
.unwrap()
.unwrap()
}, },
async { async {
source source
.set("key2", &serde_json::to_value(&config2).unwrap()) .set(
ConfigClass::Standard,
"key2",
&serde_json::to_value(&config2).unwrap(),
)
.await .await
.unwrap(); .unwrap();
source.get("key2").await.unwrap().unwrap() source
.get(ConfigClass::Standard, "key2")
.await
.unwrap()
.unwrap()
} }
); );
@@ -608,7 +1016,7 @@ mod tests {
let source1 = Arc::new(MockSource::with_data(data)); let source1 = Arc::new(MockSource::with_data(data));
let source2 = Arc::new(MockSource::new()); let source2 = Arc::new(MockSource::new());
let manager = ConfigManager::new(vec![source1.clone(), source2.clone()]); let manager = ConfigClient::new(vec![source1.clone(), source2.clone()]);
let result: TestConfig = manager.get_or_prompt().await.unwrap(); let result: TestConfig = manager.get_or_prompt().await.unwrap();
assert_eq!(result, config); assert_eq!(result, config);
@@ -626,7 +1034,7 @@ mod tests {
.unwrap(); .unwrap();
let sqlite = Arc::new(sqlite); let sqlite = Arc::new(sqlite);
let manager = ConfigManager::new(vec![sqlite.clone()]); let manager = ConfigClient::new(vec![sqlite.clone()]);
let config = TestConfig { let config = TestConfig {
name: "from_sqlite".to_string(), name: "from_sqlite".to_string(),
@@ -634,7 +1042,11 @@ mod tests {
}; };
sqlite sqlite
.set("TestConfig", &serde_json::to_value(&config).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config).unwrap(),
)
.await .await
.unwrap(); .unwrap();
@@ -653,7 +1065,7 @@ mod tests {
let sqlite = Arc::new(sqlite); let sqlite = Arc::new(sqlite);
let env_source = Arc::new(EnvSource); let env_source = Arc::new(EnvSource);
let manager = ConfigManager::new(vec![env_source.clone(), sqlite.clone()]); let manager = ConfigClient::new(vec![env_source.clone(), sqlite.clone()]);
let sqlite_config = TestConfig { let sqlite_config = TestConfig {
name: "from_sqlite".to_string(), name: "from_sqlite".to_string(),
@@ -665,7 +1077,11 @@ mod tests {
}; };
sqlite sqlite
.set("TestConfig", &serde_json::to_value(&sqlite_config).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&sqlite_config).unwrap(),
)
.await .await
.unwrap(); .unwrap();
@@ -684,7 +1100,13 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_branch_switching_scenario_deserialization_error() { async fn test_branch_switching_scenario_deserialization_falls_through() {
// ADR 020 contract: a stale value (wrong shape for the current
// struct, e.g. after a branch switch that renamed or retyped a
// field) should be treated as a cache miss for that source. The
// chain falls through; if no source has a valid value, the result
// is `NotFound`, which `get_or_prompt` turns into a re-prompt
// that overwrites the stale entry.
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
let temp_file = NamedTempFile::new().unwrap(); let temp_file = NamedTempFile::new().unwrap();
@@ -693,22 +1115,61 @@ mod tests {
.unwrap(); .unwrap();
let sqlite = Arc::new(sqlite); let sqlite = Arc::new(sqlite);
let manager = ConfigManager::new(vec![sqlite.clone()]); let manager = ConfigClient::new(vec![sqlite.clone()]);
let old_config = serde_json::json!({ let old_config = serde_json::json!({
"name": "old_config", "name": "old_config",
"count": "not_a_number" "count": "not_a_number"
}); });
sqlite.set("TestConfig", &old_config).await.unwrap(); sqlite
.set(ConfigClass::Standard, "TestConfig", &old_config)
.await
.unwrap();
let result: Result<TestConfig, ConfigError> = manager.get().await; let result: Result<TestConfig, ConfigError> = manager.get().await;
assert!(matches!(result, Err(ConfigError::Deserialization { .. }))); assert!(matches!(result, Err(ConfigError::NotFound { .. })));
}
#[tokio::test]
async fn test_deserialization_failure_falls_through_to_next_source() {
// First source has a stale value; second source has a fresh one.
// The chain should skip past the stale entry and resolve from
// the second source.
use tempfile::NamedTempFile;
let temp_file = NamedTempFile::new().unwrap();
let stale_sqlite = SqliteSource::open(temp_file.path().to_path_buf())
.await
.unwrap();
let stale_sqlite = Arc::new(stale_sqlite);
let stale = serde_json::json!({"name": "stale", "count": "not_a_number"});
stale_sqlite
.set(ConfigClass::Standard, "TestConfig", &stale)
.await
.unwrap();
let fresh_config = TestConfig {
name: "fresh".to_string(),
count: 7,
};
let mut fresh_data = std::collections::HashMap::new();
fresh_data.insert(
"TestConfig".to_string(),
serde_json::to_value(&fresh_config).unwrap(),
);
let fresh = Arc::new(MockSource::with_data(fresh_data));
let manager = ConfigClient::new(vec![stale_sqlite.clone(), fresh.clone()]);
let result: TestConfig = manager.get().await.unwrap();
assert_eq!(result, fresh_config);
} }
#[tokio::test] #[tokio::test]
async fn test_prompt_source_always_returns_none() { async fn test_prompt_source_always_returns_none() {
let source = PromptSource::new(); let source = PromptSource::new();
let result = source.get("AnyKey").await.unwrap(); let result = source.get(ConfigClass::Standard, "AnyKey").await.unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
@@ -716,7 +1177,11 @@ mod tests {
async fn test_prompt_source_set_is_noop() { async fn test_prompt_source_set_is_noop() {
let source = PromptSource::new(); let source = PromptSource::new();
let result = source let result = source
.set("AnyKey", &serde_json::json!({"test": "value"})) .set(
ConfigClass::Standard,
"AnyKey",
&serde_json::json!({"test": "value"}),
)
.await; .await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -726,13 +1191,17 @@ mod tests {
let source = PromptSource::new(); let source = PromptSource::new();
source source
.set( .set(
ConfigClass::Standard,
"TestConfig", "TestConfig",
&serde_json::json!({"name": "test", "count": 42}), &serde_json::json!({"name": "test", "count": 42}),
) )
.await .await
.unwrap(); .unwrap();
let result = source.get("TestConfig").await.unwrap(); let result = source
.get(ConfigClass::Standard, "TestConfig")
.await
.unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
@@ -750,7 +1219,7 @@ mod tests {
let prompt_source = Arc::new(PromptSource::new()); let prompt_source = Arc::new(PromptSource::new());
let manager = let manager =
ConfigManager::new(vec![source1.clone(), sqlite.clone(), prompt_source.clone()]); ConfigClient::new(vec![source1.clone(), sqlite.clone(), prompt_source.clone()]);
let result: Result<TestConfig, ConfigError> = manager.get().await; let result: Result<TestConfig, ConfigError> = manager.get().await;
assert!(matches!(result, Err(ConfigError::NotFound { .. }))); assert!(matches!(result, Err(ConfigError::NotFound { .. })));
@@ -781,14 +1250,18 @@ mod tests {
let store_source = Arc::new(StoreSource::new("test".to_string(), AlwaysErrorStore)); let store_source = Arc::new(StoreSource::new("test".to_string(), AlwaysErrorStore));
let manager = ConfigManager::new(vec![store_source.clone(), sqlite.clone()]); let manager = ConfigClient::new(vec![store_source.clone(), sqlite.clone()]);
let config = TestConfig { let config = TestConfig {
name: "from_sqlite".to_string(), name: "from_sqlite".to_string(),
count: 42, count: 42,
}; };
sqlite sqlite
.set("TestConfig", &serde_json::to_value(&config).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config).unwrap(),
)
.await .await
.unwrap(); .unwrap();
@@ -825,14 +1298,18 @@ mod tests {
let store_source = Arc::new(StoreSource::new("test".to_string(), NeverFindsStore)); let store_source = Arc::new(StoreSource::new("test".to_string(), NeverFindsStore));
let manager = ConfigManager::new(vec![store_source.clone(), sqlite.clone()]); let manager = ConfigClient::new(vec![store_source.clone(), sqlite.clone()]);
let config = TestConfig { let config = TestConfig {
name: "from_sqlite".to_string(), name: "from_sqlite".to_string(),
count: 99, count: 99,
}; };
sqlite sqlite
.set("TestConfig", &serde_json::to_value(&config).unwrap()) .set(
ConfigClass::Standard,
"TestConfig",
&serde_json::to_value(&config).unwrap(),
)
.await .await
.unwrap(); .unwrap();

View File

@@ -1,4 +1,4 @@
use crate::{ConfigError, ConfigSource}; use crate::{ConfigClass, ConfigError, ConfigSource};
use async_trait::async_trait; use async_trait::async_trait;
pub struct EnvSource; pub struct EnvSource;
@@ -9,7 +9,11 @@ fn env_key_for(config_key: &str) -> String {
#[async_trait] #[async_trait]
impl ConfigSource for EnvSource { impl ConfigSource for EnvSource {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> { async fn get(
&self,
_class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
let env_key = env_key_for(key); let env_key = env_key_for(key);
match std::env::var(&env_key) { match std::env::var(&env_key) {
@@ -27,23 +31,23 @@ impl ConfigSource for EnvSource {
} }
} }
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> { async fn set(
&self,
_class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
let env_key = env_key_for(key); let env_key = env_key_for(key);
let json_string = serde_json::to_string(value).map_err(|e| ConfigError::Serialization { let json_string = serde_json::to_string(value).map_err(|e| ConfigError::Serialization {
key: key.to_string(), key: key.to_string(),
source: e, source: e,
})?; })?;
// SAFETY: Setting environment variables is generally safe in single-threaded contexts. // Rust 2024 makes `set_var` unsafe (data race if another thread reads
// In multi-threaded contexts, this could cause races, but is acceptable for this use case // env concurrently); config is set once at startup, so this is safe.
// as config is typically set once at startup.
unsafe { unsafe {
std::env::set_var(&env_key, &json_string); std::env::set_var(&env_key, &json_string);
} }
Ok(()) Ok(())
} }
fn should_persist(&self) -> bool {
false
}
} }

View File

@@ -2,8 +2,13 @@ use async_trait::async_trait;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::fs; use tokio::fs;
use crate::{ConfigError, ConfigSource}; use crate::{ConfigClass, ConfigError, ConfigSource};
/// Local file-backed config source (`<base_path>/<key>.json`).
///
/// ⚠️ Cleartext on disk and ignores `ConfigClass`, like
/// [`SqliteSource`](crate::SqliteSource) — non-secret config only, via an
/// explicit [`ConfigClient::new`](crate::ConfigClient::new).
pub struct LocalFileSource { pub struct LocalFileSource {
base_path: PathBuf, base_path: PathBuf,
} }
@@ -24,7 +29,11 @@ impl LocalFileSource {
#[async_trait] #[async_trait]
impl ConfigSource for LocalFileSource { impl ConfigSource for LocalFileSource {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> { async fn get(
&self,
_class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
let path = self.file_path_for(key); let path = self.file_path_for(key);
match fs::read(&path).await { match fs::read(&path).await {
@@ -45,7 +54,12 @@ impl ConfigSource for LocalFileSource {
} }
} }
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> { async fn set(
&self,
_class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
fs::create_dir_all(&self.base_path).await?; fs::create_dir_all(&self.base_path).await?;
let path = self.file_path_for(key); let path = self.file_path_for(key);

View File

@@ -1,22 +1,41 @@
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use inquire::{CustomType, Password, PasswordDisplayMode, Text};
use schemars::schema::{InstanceType, RootSchema, Schema, SchemaObject, SingleOrVec};
use tokio::sync::Mutex;
use crate::{ConfigError, ConfigSource}; use crate::{Config, ConfigClass, ConfigError, ConfigSource};
pub struct PromptSource { // Serialises interactive prompts process-wide: `inquire` assumes exclusive
#[allow(dead_code)] // terminal ownership, so concurrent prompts would corrupt the terminal.
writer: Option<Arc<dyn std::io::Write + Send + Sync>>, static PROMPT_MUTEX: Mutex<()> = Mutex::const_new(());
}
pub struct PromptSource;
impl PromptSource { impl PromptSource {
pub fn new() -> Self { pub fn new() -> Self {
Self { writer: None } Self
} }
#[allow(dead_code)] /// Prompt for a `T`. `Standard` structs go through `interactive_parse`
pub fn with_writer(writer: Arc<dyn std::io::Write + Send + Sync>) -> Self { /// (full nested support); `Secret` structs go through a flat-field
Self { /// walker that reads `#[config(secret)]` fields via `inquire::Password`.
writer: Some(writer), pub async fn prompt_for<T: Config>(&self) -> Result<T, ConfigError> {
let _guard = PROMPT_MUTEX.lock().await;
let banner = format!(
"── Configuring `{}` ({:?}) — please fill the fields below ──",
T::KEY,
T::CLASS,
);
// inquire renders on stderr; match that channel and pad with blank
// lines so the banner stays separate from preceding log output.
eprintln!();
eprintln!("{banner}");
eprintln!();
match T::CLASS {
ConfigClass::Standard => {
T::parse_to_obj().map_err(|e| ConfigError::PromptError(e.to_string()))
}
ConfigClass::Secret => prompt_secret_struct::<T>(),
} }
} }
} }
@@ -29,15 +48,153 @@ impl Default for PromptSource {
#[async_trait] #[async_trait]
impl ConfigSource for PromptSource { impl ConfigSource for PromptSource {
async fn get(&self, _key: &str) -> Result<Option<serde_json::Value>, ConfigError> { async fn get(
&self,
_class: ConfigClass,
_key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
Ok(None) Ok(None)
} }
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> { async fn set(
&self,
_class: ConfigClass,
_key: &str,
_value: &serde_json::Value,
) -> Result<(), ConfigError> {
Ok(()) Ok(())
} }
}
fn should_persist(&self) -> bool { // Walks a Secret struct's schema, reading `T::SECRET_FIELDS` via Password
false // and other fields via type-appropriate prompts. Only flat primitive
// fields are supported; anything else returns a clear PromptError.
fn prompt_secret_struct<T: Config>() -> Result<T, ConfigError> {
let root: RootSchema = schemars::schema_for!(T);
let object = root.schema.object.as_deref().ok_or_else(|| {
ConfigError::PromptError(format!(
"Secret struct `{}` has no JSON-object schema; the walker only \
supports plain structs",
T::KEY
))
})?;
let mut json = serde_json::Map::with_capacity(object.properties.len());
for (field_name, field_schema) in &object.properties {
let field_object = schema_object(field_schema, T::KEY, field_name)?;
let is_secret = T::SECRET_FIELDS.contains(&field_name.as_str());
let value = prompt_field::<T>(field_name, field_object, is_secret)?;
json.insert(field_name.clone(), value);
}
serde_json::from_value(serde_json::Value::Object(json)).map_err(|e| {
ConfigError::PromptError(format!(
"Failed to deserialize prompted values into `{}`: {e}",
T::KEY
))
})
}
fn schema_object<'a>(
schema: &'a Schema,
struct_name: &str,
field_name: &str,
) -> Result<&'a SchemaObject, ConfigError> {
match schema {
Schema::Object(obj) => Ok(obj),
Schema::Bool(_) => Err(ConfigError::PromptError(format!(
"Secret struct `{struct_name}` field `{field_name}` resolved to a \
boolean JSON-schema; use a concrete type."
))),
}
}
// Picks the inquire widget for one field from its JsonSchema. Secret
// strings use Password; other primitives use type-appropriate input.
// Returns Err for unsupported shapes — never a silent unmasked fallback.
fn prompt_field<T: Config>(
field_name: &str,
schema: &SchemaObject,
is_secret: bool,
) -> Result<serde_json::Value, ConfigError> {
let instance_type = schema.instance_type.as_ref().ok_or_else(|| {
ConfigError::PromptError(format!(
"Secret struct `{}` field `{field_name}` is not a flat primitive \
(string/integer/number/bool); flatten it or extend the walker.",
T::KEY
))
})?;
let single = match instance_type {
SingleOrVec::Single(boxed) => **boxed,
SingleOrVec::Vec(_) => {
return Err(ConfigError::PromptError(format!(
"Secret struct `{}` field `{field_name}` has a multi-type schema \
(e.g. nullable); the walker only handles single primitives.",
T::KEY
)));
}
};
let label = format!("{field_name}:");
match single {
InstanceType::String => {
if is_secret {
// Masked mode echoes `*` per keystroke (inquire's default
// Hidden mode shows nothing until enter).
let raw = Password::new(&label)
.with_display_mode(PasswordDisplayMode::Masked)
.without_confirmation()
.prompt()
.map_err(|e| {
ConfigError::PromptError(format!(
"Password prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::Value::String(raw))
} else {
let raw = Text::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Text prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::Value::String(raw))
}
}
InstanceType::Integer => {
// i64 covers Config integer fields; serde narrows on deserialize.
let n = CustomType::<i64>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Integer prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::json!(n))
}
InstanceType::Number => {
let n = CustomType::<f64>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Number prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::json!(n))
}
InstanceType::Boolean => {
let b = CustomType::<bool>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Boolean prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::Value::Bool(b))
}
other => Err(ConfigError::PromptError(format!(
"Secret struct `{}` field `{field_name}` has unsupported type \
`{other:?}`; the walker only handles flat primitives.",
T::KEY
))),
} }
} }

View File

@@ -3,8 +3,15 @@ use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use std::path::PathBuf; use std::path::PathBuf;
use tokio::fs; use tokio::fs;
use crate::{ConfigError, ConfigSource}; use crate::{ConfigClass, ConfigError, ConfigSource};
/// Local SQLite-backed config cache at `<data_dir>/<namespace>/config.db`.
///
/// ⚠️ Stores values as cleartext JSON and ignores `ConfigClass`, so it must
/// never hold `Secret`-class config — it is excluded from the canonical
/// chain ([`ConfigClient::for_namespace`](crate::ConfigClient::for_namespace))
/// and only usable via an explicit [`ConfigClient::new`](crate::ConfigClient::new)
/// for non-secret offline caching.
pub struct SqliteSource { pub struct SqliteSource {
pool: SqlitePool, pool: SqlitePool,
} }
@@ -37,11 +44,14 @@ impl SqliteSource {
Ok(Self { pool }) Ok(Self { pool })
} }
pub async fn default() -> Result<Self, ConfigError> { /// Scope the database to `namespace` so harmony binaries on one machine
/// don't share a `config.db`. Use the same namespace as `StoreSource`.
pub async fn for_namespace(namespace: &str) -> Result<Self, ConfigError> {
let path = crate::default_config_dir() let path = crate::default_config_dir()
.ok_or_else(|| { .ok_or_else(|| {
ConfigError::SqliteError("Could not determine default config directory".into()) ConfigError::SqliteError("Could not determine default config directory".into())
})? })?
.join(namespace)
.join("config.db"); .join("config.db");
Self::open(path).await Self::open(path).await
} }
@@ -49,7 +59,11 @@ impl SqliteSource {
#[async_trait] #[async_trait]
impl ConfigSource for SqliteSource { impl ConfigSource for SqliteSource {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> { async fn get(
&self,
_class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM config WHERE key = ?") let row: Option<(String,)> = sqlx::query_as("SELECT value FROM config WHERE key = ?")
.bind(key) .bind(key)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -69,7 +83,12 @@ impl ConfigSource for SqliteSource {
} }
} }
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> { async fn set(
&self,
_class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
let json_string = serde_json::to_string(value).map_err(|e| ConfigError::Serialization { let json_string = serde_json::to_string(value).map_err(|e| ConfigError::Serialization {
key: key.to_string(), key: key.to_string(),
source: e, source: e,

View File

@@ -1,7 +1,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use harmony_secret::SecretStore; use harmony_secret::SecretStore;
use log::warn;
use crate::{ConfigError, ConfigSource}; use crate::{ConfigClass, ConfigError, ConfigSource};
pub struct StoreSource<S> { pub struct StoreSource<S> {
namespace: String, namespace: String,
@@ -15,8 +16,14 @@ impl<S> StoreSource<S> {
} }
#[async_trait] #[async_trait]
// TODO(ADR 020-1): forward `_class` to `SecretStore` once that trait accepts
// a class hint; kept here so the ConfigSource boundary already matches ADR 020.
impl<S: SecretStore + 'static> ConfigSource for StoreSource<S> { impl<S: SecretStore + 'static> ConfigSource for StoreSource<S> {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError> { async fn get(
&self,
_class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
match self.store.get_raw(&self.namespace, key).await { match self.store.get_raw(&self.namespace, key).await {
Ok(bytes) => { Ok(bytes) => {
let value: serde_json::Value = let value: serde_json::Value =
@@ -27,11 +34,23 @@ impl<S: SecretStore + 'static> ConfigSource for StoreSource<S> {
Ok(Some(value)) Ok(Some(value))
} }
Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None), Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None),
Err(_) => Ok(None), // Log before swallowing: a down/misconfigured OpenBao must not look identical to "key absent".
Err(e) => {
warn!(
"StoreSource: get for key '{key}' failed ({e}); treating as \
absent and falling through to the next source"
);
Ok(None)
}
} }
} }
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> { async fn set(
&self,
_class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
let bytes = serde_json::to_vec(value).map_err(|e| ConfigError::Serialization { let bytes = serde_json::to_vec(value).map_err(|e| ConfigError::Serialization {
key: key.to_string(), key: key.to_string(),
source: e, source: e,

View File

@@ -1,17 +1,23 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use proc_macro_crate::{FoundCrate, crate_name}; use proc_macro_crate::{FoundCrate, crate_name};
use quote::quote; use quote::quote;
use syn::{DeriveInput, Ident, parse_macro_input}; use syn::{Attribute, Data, DeriveInput, Fields, Ident, parse_macro_input};
#[proc_macro_derive(Config)] /// Derives `Config`, emitting `KEY`, `CLASS`, and `SECRET_FIELDS`. Mark a
/// field — or the whole struct — `#[config(secret)]` to elevate it to
/// `ConfigClass::Secret`. See `Config::SECRET_FIELDS` for the no-rename rule.
#[proc_macro_derive(Config, attributes(config))]
pub fn derive_config(input: TokenStream) -> TokenStream { pub fn derive_config(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput); let input = parse_macro_input!(input as DeriveInput);
let struct_ident = &input.ident; let struct_ident = &input.ident;
let key = struct_ident.to_string(); let key = struct_ident.to_string();
// Always emit a fully-qualified path: examples are separate binary
// crates whose `crate::` root lacks harmony_config's items, so the
// `extern crate self` alias in the lib makes `::harmony_config` resolve.
let config_crate_path = match crate_name("harmony_config") { let config_crate_path = match crate_name("harmony_config") {
Ok(FoundCrate::Itself) => quote!(crate), Ok(FoundCrate::Itself) => quote!(::harmony_config),
Ok(FoundCrate::Name(name)) => { Ok(FoundCrate::Name(name)) => {
let ident = Ident::new(&name, proc_macro2::Span::call_site()); let ident = Ident::new(&name, proc_macro2::Span::call_site());
quote!(::#ident) quote!(::#ident)
@@ -23,11 +29,63 @@ pub fn derive_config(input: TokenStream) -> TokenStream {
} }
}; };
let struct_level_secret = has_secret_attr(&input.attrs);
// A struct-level `#[config(secret)]` makes every named field secret.
let mut secret_field_names: Vec<String> = Vec::new();
if let Data::Struct(ref data) = input.data
&& let Fields::Named(named) = &data.fields
{
for field in &named.named {
let Some(ident) = field.ident.as_ref() else {
continue;
};
if struct_level_secret || has_secret_attr(&field.attrs) {
secret_field_names.push(ident.to_string());
}
}
}
let is_secret = struct_level_secret || !secret_field_names.is_empty();
let class = if is_secret {
quote!(#config_crate_path::ConfigClass::Secret)
} else {
quote!(#config_crate_path::ConfigClass::Standard)
};
let secret_fields_const = if secret_field_names.is_empty() {
quote!() // fall back to the trait default `&[]`
} else {
let names = secret_field_names.iter().map(|n| quote!(#n));
quote! {
const SECRET_FIELDS: &'static [&'static str] = &[ #( #names ),* ];
}
};
let expanded = quote! { let expanded = quote! {
impl #config_crate_path::Config for #struct_ident { impl #config_crate_path::Config for #struct_ident {
const KEY: &'static str = #key; const KEY: &'static str = #key;
const CLASS: #config_crate_path::ConfigClass = #class;
#secret_fields_const
} }
}; };
TokenStream::from(expanded) TokenStream::from(expanded)
} }
fn has_secret_attr(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("config") {
return false;
}
let mut found = false;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("secret") {
found = true;
}
Ok(())
});
found
})
}