Files
harmony/harmony_config/examples/openbao_chain.rs
Sylvain Tremblay fd70f9fdc8 refactor(harmony_config): rename ConfigManager → ConfigClient
"Manager" is a vague suffix, and the workspace had a name clash:
opnsense-config defines its own unrelated `ConfigManager` trait.
The type's real role is the consumer-facing facade you call to
get / set / get_or_prompt over an ordered source chain — a client
for accessing config. Renamed to `ConfigClient` (chosen over
`ConfigStore`, which collides with the existing SecretStore /
StoreSource nomenclature, and `ConfigResolver`, which undersells
the write/prompt side).

Pure mechanical rename, no behaviour change:
- `ConfigManager` → `ConfigClient`
- `ConfigManagerBuilder` → `ConfigClientBuilder`
- private static `CONFIG_MANAGER` → `CONFIG_CLIENT`
- doc prose + intra-doc links across harmony_config, its three
  examples, and examples/harmony_sso (incl. README + plan.md)

The opnsense-config `ConfigManager` trait is a different crate and
is untouched — the rename removes the cross-crate clash.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:54:19 -04:00

488 lines
20 KiB
Rust

//! # Dev-binary template: harmony_config against a harmony-sso-example stack
//!
//! This example is the consumer-side counterpart to
//! `examples/harmony_sso`. Where `harmony_sso` puts the SSO + OpenBao
//! infrastructure in place, this example shows how a developer writes
//! a new harmony binary that *uses* that infrastructure to manage its
//! configuration and secrets.
//!
//! ## Prerequisite — the harmony-sso-example stack must be running
//!
//! Before running this example, run the harmony-sso-example demo at
//! least once so that:
//!
//! - the local k3d cluster `harmony-example` is up,
//! - OpenBao is deployed, initialised, unsealed, and reachable through
//! the ingress at `bao.harmony.local:8080`,
//! - Zitadel is deployed with a device-code OAuth application named
//! `harmony-cli`, reachable through the ingress at
//! `sso.harmony.local:8080`,
//! - `/etc/hosts` resolves both hostnames to `127.0.0.1`.
//!
//! Run the prereq stack:
//! ```bash
//! cargo run -p example-harmony-sso
//! ```
//!
//! Then run this example:
//! ```bash
//! cargo run -p harmony_config --example openbao_chain
//! ```
//!
//! ## Use cases demonstrated
//!
//! 1. Build a `ConfigClient` with the chain a real harmony binary
//! would use: `EnvSource → StoreSource<OpenbaoSecretStore> → PromptSource`.
//! OpenBao is the team-scale source of truth. There is no local
//! cache layer by default — SQLite is opt-in only
//! (`builder(..).with_sqlite()`) because it stores values as
//! cleartext on disk and can't safely hold Secret-class config.
//! 2. Authenticate to OpenBao via the Zitadel device flow on first run,
//! then cache the session so subsequent runs are silent.
//! 3. `manager.get::<T>()` — read existing config.
//! 4. `manager.set(&value)` — persist to every writable source.
//! 5. `manager.get_or_prompt::<T>()` — interactively gather config the
//! first time, then cache it.
//! 6. **Edit existing config** — when `get` returns a cached value the
//! example shows it and asks the operator whether to update it.
//! If they say yes, `PromptSource::prompt_for` collects new values
//! and `manager.set` persists them. This is the canonical
//! "re-run to reconfigure" loop for a long-lived binary.
//! 7. Round-trip both a `ConfigClass::Standard` struct (`AppConfig`) and
//! a `ConfigClass::Secret` struct (`DatabaseCredentials`) through the
//! same chain — no per-call ceremony.
//! 8. **Masked input on Secret-class structs** (`ApiCredentials`).
//! `#[config(secret)]` fields are read via `inquire::Password`
//! (echoed as `*`) when the chain falls through to
//! `PromptSource`. The Standard `client_id` field uses normal
//! text input. The same step shows the
//! "cached → confirm → re-prompt" update loop applied to a
//! Secret struct.
//! 9. **Safe logging via `.masked()`.** Every log line that prints a
//! `Config` value goes through `value.masked()`, which masks
//! `#[config(secret)]` fields as `"****"`. Standard-class
//! values render unmodified, so the pattern is safe-by-default
//! at every call site.
//! 10. Demonstrate the env-var override: `HARMONY_CONFIG_AppConfig`
//! short-circuits the chain.
//! 11. Auth-method override: `OPENBAO_TOKEN` or
//! `OPENBAO_USERNAME`+`OPENBAO_PASSWORD` bypass SSO for testing
//! against a non-SSO OpenBao.
//!
//! ## Configuration — built in code, passed to the builder
//!
//! Connection params are computed in plain Rust and handed to
//! `ConfigClient::builder("...").openbao_overrides(...)`, so the
//! example never mutates the process environment to configure
//! itself. (Rust 2024 makes `std::env::set_var` `unsafe`; we'd
//! rather not pay that cost for plumbing that can live as data.)
//! Any env var that's already set takes precedence, so you can
//! still point the example at a different OpenBao by exporting
//! variables from your shell:
//!
//! | Variable | In-code default | Source of truth |
//! |---|---|---|
//! | `OPENBAO_URL` | `http://bao.harmony.local:8080` | this file |
//! | `HARMONY_SSO_URL` | `http://sso.harmony.local:8080` | this file |
//! | `HARMONY_SSO_CLIENT_ID` | auto-discovered from harmony's cache | **see disclaimer below** |
//! | `OPENBAO_KV_MOUNT` | builder default `secret` | env |
//! | `OPENBAO_AUTH_MOUNT` | builder default `jwt` | env, matches `OpenbaoSetupScore` |
//! | `OPENBAO_TOKEN` | (unset) | optional override |
//! | `OPENBAO_USERNAME`/`OPENBAO_PASSWORD` | (unset) | optional override |
//!
//! ## ⚠ `HARMONY_SSO_CLIENT_ID` auto-discovery — example scaffolding only
//!
//! In production, `HARMONY_SSO_CLIENT_ID` is configured by whatever
//! provisions the binary's environment — a systemd unit, a Kubernetes
//! ConfigMap, a `direnv`/`.envrc` file, your CI job, etc. The value is
//! set externally; the binary just reads it.
//!
//! This example, however, runs locally against a stack that is brought
//! up via `examples/harmony_sso`, which generates the client_id
//! dynamically (it varies per Zitadel install). To make the example
//! "just work" without forcing the operator to look the value up
//! manually, we **read it out of harmony's own cache file at
//! `~/.local/share/harmony/zitadel/client-config.json`** and export it
//! back to the environment.
//!
//! **Do not copy the auto-discovery helper into a real harmony binary.**
//! It reads from a path that is an implementation detail of
//! `ZitadelSetupScore`, not a public contract. Production binaries
//! must take `HARMONY_SSO_CLIENT_ID` from the environment.
use std::path::PathBuf;
use harmony_config::{Config, ConfigClient, ConfigExt, OpenbaoConnection, PromptSource};
use inquire::Confirm;
use log::info;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Config types
// ---------------------------------------------------------------------------
/// Plain operational config. Standard class — no `#[config(secret)]`
/// fields, so backends are free to log / display / cache it however
/// they like.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct AppConfig {
host: String,
port: u16,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
host: "production.example.com".to_string(),
port: 443,
}
}
}
/// Mixed-sensitivity config. The `password` field is tagged
/// `#[config(secret)]`, which elevates the whole struct to
/// `ConfigClass::Secret`. The struct still round-trips through the
/// same `ConfigClient` chain — the class is a backend hint, not a
/// dispatch.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct DatabaseCredentials {
host: String,
username: String,
#[config(secret)]
password: String,
}
impl Default for DatabaseCredentials {
fn default() -> Self {
Self {
host: "db.example.com".to_string(),
username: "app_rw".to_string(),
password: "rotate-me-please".to_string(),
}
}
}
/// A struct intentionally lacking a `Default` impl so we can show the
/// `get_or_prompt` flow: on first run there is no cached value and no
/// in-code default, so the manager prompts interactively, stores the
/// answers, and subsequent runs find the value without prompting.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct UserPreferences {
display_name: String,
theme: String,
}
/// Secret-class struct without a `Default` impl. Exists specifically
/// to exercise the masked-prompt path: on first run the chain falls
/// through to `PromptSource`, the walker sees
/// `Config::CLASS == Secret`, and routes each tagged field through
/// `inquire::Password` (echoed as `*`) instead of `inquire::Text`.
/// `client_id` stays a plain text prompt because it isn't tagged.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
struct ApiCredentials {
client_id: String,
#[config(secret)]
client_secret: String,
#[config(secret)]
refresh_token: String,
}
// ---------------------------------------------------------------------------
// Connection params — built in code, passed to ConfigClient::builder
// ---------------------------------------------------------------------------
/// Path harmony writes to when `ZitadelSetupScore` provisions the
/// `harmony-cli` device-code application. **Implementation detail of
/// the harmony-sso-example stack** — not a public contract. Used here
/// only to spare the operator from manually exporting
/// `HARMONY_SSO_CLIENT_ID` after every `cargo run -p example-harmony-sso`.
fn zitadel_client_config_path() -> Option<PathBuf> {
Some(
directories::BaseDirs::new()?
.data_dir()
.join("harmony")
.join("zitadel")
.join("client-config.json"),
)
}
/// Auto-discovery for the `harmony-cli` OIDC client_id, from harmony's
/// own cache file.
///
/// ⚠ Example scaffolding only. A real harmony binary must read
/// `HARMONY_SSO_CLIENT_ID` from the environment — it is configured by
/// whatever provisions the binary (systemd, Kubernetes, direnv, CI).
/// Do not copy this helper into production code.
fn discover_zitadel_client_id() -> Option<String> {
let path = zitadel_client_config_path()?;
let bytes = std::fs::read(&path).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v.get("apps")?
.get("harmony-cli")?
.as_str()
.map(str::to_string)
}
/// Build the OpenBao connection params that target the
/// harmony-sso-example stack. We compute the values in plain Rust
/// and pass them through `ConfigClientBuilder::openbao_overrides`,
/// so the example doesn't have to mutate the process environment to
/// configure itself.
///
/// Each field honours an existing env var if set (so an operator
/// running this against a different OpenBao can override from their
/// shell); otherwise we fall back to the harmony-sso defaults.
fn harmony_sso_example_connection() -> anyhow::Result<OpenbaoConnection> {
let sso_client_id = std::env::var("HARMONY_SSO_CLIENT_ID")
.ok()
.or_else(|| {
let discovered = discover_zitadel_client_id();
if let Some(ref id) = discovered {
info!(
"openbao_chain: auto-discovered HARMONY_SSO_CLIENT_ID={id} \
from harmony's local cache (EXAMPLE-ONLY; production binaries \
must read this from the environment)"
);
}
discovered
})
.ok_or_else(|| {
anyhow::anyhow!(
"HARMONY_SSO_CLIENT_ID is not set and no client-config cache was \
found at {:?}. Either:\n (a) bring up the harmony-sso-example \
stack first (`cargo run -p example-harmony-sso`), which writes \
that file, or\n (b) export HARMONY_SSO_CLIENT_ID yourself \
pointing at your Zitadel app.",
zitadel_client_config_path()
)
})?;
Ok(OpenbaoConnection {
url: Some(
std::env::var("OPENBAO_URL")
.unwrap_or_else(|_| "http://bao.harmony.local:8080".to_string()),
),
sso_url: Some(
std::env::var("HARMONY_SSO_URL")
.unwrap_or_else(|_| "http://sso.harmony.local:8080".to_string()),
),
sso_client_id: Some(sso_client_id),
// `kv_mount` (defaults to "secret") and `auth_mount`
// (defaults to "jwt") match what OpenbaoSetupScore
// configures in harmony-sso-example, so we leave them at
// None to use the builder's defaults. Token / userpass
// overrides are intentionally absent — SSO is the happy
// path; export OPENBAO_TOKEN from your shell to force the
// token-auth fallback.
..Default::default()
})
}
// (Chain construction is `ConfigClient::builder(...)` — see main().)
// ---------------------------------------------------------------------------
// Use-case walkthroughs
// ---------------------------------------------------------------------------
/// First time around there's nothing stored, so we set a default;
/// subsequent runs read the stored value back. Logs the struct's
/// `CLASS` so the operator can see Standard / Secret routing.
///
/// Logs route through `.masked()` so any `#[config(secret)]` field
/// renders as `****` in the output. For Standard-class types this
/// is a no-op (`SECRET_FIELDS` is empty), so it's safe-by-default
/// to always go through `.masked()` for log lines that print a
/// `Config` value.
async fn demo_round_trip<T>(manager: &ConfigClient, default_value: T) -> anyhow::Result<()>
where
T: Config + std::fmt::Debug + Clone + PartialEq,
{
info!(
"[{key}] CLASS={class:?}, attempting read…",
key = T::KEY,
class = T::CLASS,
);
match manager.get::<T>().await {
Ok(found) => {
info!("[{}] read existing value: {:?}", T::KEY, found.masked());
}
Err(harmony_config::ConfigError::NotFound { .. }) => {
info!("[{}] not found — storing default and reading back", T::KEY);
manager.set(&default_value).await?;
let retrieved: T = manager.get().await?;
anyhow::ensure!(
retrieved == default_value,
"Round-trip mismatch for {}: stored != retrieved",
T::KEY
);
info!("[{}] round-trip verified: {:?}", T::KEY, retrieved.masked());
}
Err(e) => return Err(e.into()),
}
Ok(())
}
/// Shows that `HARMONY_CONFIG_<KEY>` always wins, regardless of what's
/// in OpenBao. Useful for per-process tweaks (a CI job, a
/// one-off dry-run) without mutating shared state.
///
/// This is the **only** `unsafe` block in the example — and it's not
/// a workaround. The env-var override mechanism is the feature
/// being demonstrated; calling `set_var` here is the demo, not
/// configuration plumbing. (For chain configuration we pass
/// `OpenbaoConnection` through the builder, which avoids
/// `env::set_var` entirely.)
async fn demo_env_override(manager: &ConfigClient) -> anyhow::Result<()> {
info!("[env-override] setting HARMONY_CONFIG_AppConfig in-process");
let override_value = AppConfig {
host: "ci-override.example.com".to_string(),
port: 9090,
};
// SAFETY: `std::env::set_var` is unsafe under Rust 2024 because
// POSIX `setenv` races against concurrent libc reads on other
// threads. We're inside the tokio current-thread runtime and no
// other harmony code is reading `HARMONY_CONFIG_AppConfig`
// outside this manager's `EnvSource::get`, which we await
// immediately after the write. Single-threaded + bounded
// window → no race.
unsafe {
std::env::set_var(
"HARMONY_CONFIG_AppConfig",
serde_json::to_string(&override_value)?,
);
}
let seen: AppConfig = manager.get().await?;
anyhow::ensure!(
seen == override_value,
"env override did not win: got {seen:?}, expected {override_value:?}"
);
info!("[env-override] env source short-circuited the chain: {seen:?}");
// SAFETY: same conditions as the set_var above.
unsafe {
std::env::remove_var("HARMONY_CONFIG_AppConfig");
}
Ok(())
}
/// Two flows in one step, depending on whether the value is already
/// cached:
///
/// - **Cold run (cache miss).** No cached value and no `Default` impl
/// on `T`, so `get_or_prompt` prompts interactively (via `inquire`,
/// requires a TTY) and persists the answers.
///
/// - **Subsequent run (cache hit).** The value is found in OpenBao
/// without prompting. We then *show* the cached value and
/// ask via `inquire::Confirm` whether the operator wants to update
/// it. On "yes", `PromptSource::prompt_for` re-collects every
/// field and `manager.set` persists the new value to every
/// writable source. On "no", the cached value stands.
///
/// When `T::CLASS == Secret`, the prompt walker uses
/// `inquire::Password` for `#[config(secret)]` fields — input is
/// echoed as `*` instead of the real characters. All log lines
/// route through `.masked()` so cached / updated values render
/// with their secret fields replaced by `"****"`.
///
/// Both branches require a TTY; on a CI runner with no terminal the
/// step fails — that's the same contract every interactive harmony
/// binary has.
async fn demo_get_or_prompt<T>(manager: &ConfigClient) -> anyhow::Result<()>
where
T: Config + std::fmt::Debug + Clone + PartialEq,
{
info!("[{key}] CLASS={class:?}", key = T::KEY, class = T::CLASS,);
match manager.get::<T>().await {
Ok(existing) => {
info!("[{}] cached value found: {:?}", T::KEY, existing.masked());
let want_change = Confirm::new(&format!("Update `{}` with new values?", T::KEY))
.with_default(false)
.prompt()
.map_err(|e| anyhow::anyhow!("Confirm prompt failed: {e}"))?;
if want_change {
// PromptSource::prompt_for holds the process-wide
// prompt mutex, so any background log noise gets
// serialised against the prompt. For Secret-class
// T it also routes #[config(secret)] string fields
// through inquire::Password (input echoed as `*`).
let updated: T = PromptSource::new().prompt_for().await?;
manager.set(&updated).await?;
info!(
"[{}] updated and persisted to every writable source: {:?}",
T::KEY,
updated.masked(),
);
} else {
info!("[{}] keeping cached value (no update requested)", T::KEY);
}
}
Err(harmony_config::ConfigError::NotFound { .. }) => {
info!(
"[{}] no cached value — get_or_prompt will gather + persist",
T::KEY
);
let resolved: T = manager.get_or_prompt().await?;
info!("[{}] resolved: {:?}", T::KEY, resolved.masked());
}
Err(e) => return Err(e.into()),
}
Ok(())
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_secs()
.init();
// Compute connection params in plain Rust (no env mutation —
// see module-level note about avoiding `unsafe`) and pass them
// to the builder. A production binary leaves this off and
// relies on env vars set by its deployment.
let openbao = harmony_sso_example_connection()?;
let manager = ConfigClient::builder("harmony")
.openbao_overrides(openbao)
.build()
.await?;
println!("\n=== harmony_config dev-binary demo ===\n");
info!("Step 1/5 — round-trip Standard-class struct (AppConfig)");
demo_round_trip(&manager, AppConfig::default()).await?;
info!(
"Step 2/5 — round-trip Secret-class struct (DatabaseCredentials); logs route through .masked()"
);
demo_round_trip(&manager, DatabaseCredentials::default()).await?;
info!("Step 3/5 — env-var override demo");
demo_env_override(&manager).await?;
info!("Step 4/5 — get_or_prompt on a Standard struct (UserPreferences); plain text prompts");
demo_get_or_prompt::<UserPreferences>(&manager).await?;
info!(
"Step 5/5 — get_or_prompt on a Secret struct (ApiCredentials); \
#[config(secret)] fields are read with inquire::Password (`*` masked input)"
);
demo_get_or_prompt::<ApiCredentials>(&manager).await?;
println!("\n=== Done. ===");
println!("Stored values live in:");
println!(
" • OpenBao @ secret/harmony/<KEY> (browse via http://bao.harmony.local:8080/ui)"
);
println!(" • OIDC cache @ ~/.local/share/harmony/secrets/oidc_session_*");
println!(
" (no local SQLite cache — not in the default chain; opt in with builder().with_sqlite())"
);
Ok(())
}