"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>
488 lines
20 KiB
Rust
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(())
|
|
}
|