Files
harmony/ROADMAP/01-config-crate.md

178 lines
7.6 KiB
Markdown

# Phase 1: Harden `harmony_config`, Validate UX, Zero-Setup Starting Point
## Goal
Make `harmony_config` production-ready with a seamless first-run experience: clone, run, get prompted, values persist locally. Then progressively add team-scale backends (OpenBao, Zitadel SSO) without changing any calling code.
## Current State
`harmony_config` exists with:
- `Config` trait + `#[derive(Config)]` macro
- `ConfigManager` with ordered source chain
- Four `ConfigSource` implementations:
- `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars
- `LocalFileSource` — reads/writes `{key}.json` files from a directory
- `PromptSource`**stub** (returns `None` / no-ops on set)
- `StoreSource<S: SecretStore>` — wraps any `harmony_secret::SecretStore` backend
- 12 unit tests (mock source, env, local file)
- Global `CONFIG_MANAGER` static with `init()`, `get()`, `get_or_prompt()`, `set()`
- **Zero workspace consumers** — nothing calls `harmony_config` yet
## Tasks
### 1.1 Add `SqliteSource` as the default zero-setup backend
Replace `LocalFileSource` (JSON files scattered in a directory) with a single SQLite database as the default local backend. `sqlx` with SQLite is already a workspace dependency.
```rust
// harmony_config/src/source/sqlite.rs
pub struct SqliteSource {
pool: SqlitePool,
}
impl SqliteSource {
/// Opens or creates the database at the given path.
/// Creates the `config` table if it doesn't exist.
pub async fn open(path: PathBuf) -> Result<Self, ConfigError>
/// Uses the default Harmony data directory:
/// ~/.local/share/harmony/config.db (Linux)
pub async fn default() -> Result<Self, ConfigError>
}
#[async_trait]
impl ConfigSource for SqliteSource {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>
}
```
Schema:
```sql
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
**Tests**:
- `test_sqlite_set_and_get` — round-trip a `TestConfig` struct
- `test_sqlite_get_returns_none_when_missing` — key not in DB
- `test_sqlite_overwrites_on_set` — set twice, get returns latest
- `test_sqlite_concurrent_access` — two tasks writing different keys simultaneously
- All tests use `tempfile::NamedTempFile` for the DB path
### 1.1.1 Add Config example to show exact DX and confirm functionality
Create `harmony_config/examples` that show how to use config crate with various backends.
Show how to use the derive macros, how to store secrets in a local backend or a zitadel + openbao backend, how to fetch them from environment variables, etc. Explicitely outline the dependencies for examples with dependencies in a comment at the top. Explain how to configure zitadel + openbao for this backend. The local backend should have zero dependency, zero setup, storing its config/secrets with sane defaults.
Also show that a Config with default values will not prompt for values with defaults.
### 1.2 Make `PromptSource` functional
Currently `PromptSource::get()` returns `None` and `set()` is a no-op. Wire it to `interactive_parse::InteractiveParseObj`:
```rust
#[async_trait]
impl ConfigSource for PromptSource {
async fn get(&self, _key: &str) -> Result<Option<serde_json::Value>, ConfigError> {
// PromptSource never "has" a value — it's always a fallback.
// The actual prompting happens in ConfigManager::get_or_prompt().
Ok(None)
}
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> {
// Prompt source doesn't persist. Other sources in the chain do.
Ok(())
}
}
```
The prompting logic is already in `ConfigManager::get_or_prompt()` via `T::parse_to_obj()`. The `PromptSource` struct exists mainly to hold the `PROMPT_MUTEX` and potentially a custom writer for TUI integration later.
**Key fix**: Ensure `get_or_prompt()` persists the prompted value to the **first writable source** (SQLite), not to all sources. Current code tries all sources — this is wrong for prompt-then-persist because you don't want to write prompted values to env vars.
```rust
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
match self.get::<T>().await {
Ok(config) => Ok(config),
Err(ConfigError::NotFound { .. }) => {
let config = T::parse_to_obj()
.map_err(|e| ConfigError::PromptError(e.to_string()))?;
let value = serde_json::to_value(&config)
.map_err(|e| ConfigError::Serialization { key: T::KEY.to_string(), source: e })?;
// Persist to the first source that accepts writes (skip EnvSource)
for source in &self.sources {
if source.set(T::KEY, &value).await.is_ok() {
break;
}
}
Ok(config)
}
Err(e) => Err(e),
}
}
```
**Tests**:
- `test_get_or_prompt_persists_to_first_writable_source` — mock source chain where first source is read-only, second is writable. Verify prompted value lands in second source.
### 1.3 Integration test: full resolution chain
Test the complete priority chain: env > sqlite > prompt.
```rust
#[tokio::test]
async fn test_full_resolution_chain() {
// 1. No env var, no SQLite entry → prompting would happen
// (test with mock/pre-seeded source instead of real stdin)
// 2. Set in SQLite → get() returns SQLite value
// 3. Set env var → get() returns env value (overrides SQLite)
// 4. Remove env var → get() falls back to SQLite
}
#[tokio::test]
async fn test_branch_switching_scenario() {
// Simulate: struct shape changes between branches.
// Old value in SQLite doesn't match new struct.
// get() should return Deserialization error.
// get_or_prompt() should re-prompt and overwrite.
}
```
### 1.4 Validate Zitadel + OpenBao integration path
This is not about building the full OIDC flow yet. It's about validating that the architecture supports it by adding `StoreSource<OpenbaoSecretStore>` to the source chain.
**Validate**:
- `ConfigManager::new(vec![EnvSource, SqliteSource, StoreSource<Openbao>])` compiles and works
- When OpenBao is unreachable, the chain falls through to SQLite gracefully (no panic)
- When OpenBao has the value, it's returned and SQLite is not queried
**Document** the target Zitadel OIDC flow as an ADR (RFC 8628 device authorization grant), but don't implement it yet. The `StoreSource` wrapping OpenBao with JWT auth is the integration point — Zitadel provides the JWT, OpenBao validates it.
### 1.5 UX validation checklist
Before this phase is done, manually verify:
- [ ] `cargo run --example postgresql` with no env vars → prompts for nothing (postgresql doesn't use secrets yet, but the config system initializes cleanly)
- [ ] An example that uses `SecretManager` today (e.g., `brocade_snmp_server`) → when migrated to `harmony_config`, first run prompts, second run reads from SQLite
- [ ] Setting `HARMONY_CONFIG_BrocadeSwitchAuth='{"host":"...","user":"...","password":"..."}'` → skips prompt, uses env value
- [ ] Deleting `~/.local/share/harmony/config.db` → re-prompts on next run
## Deliverables
- [ ] `SqliteSource` implementation with tests
- [ ] Functional `PromptSource` (or validated that current `get_or_prompt` flow is correct)
- [ ] Fix `get_or_prompt` to persist to first writable source, not all sources
- [ ] Integration tests for full resolution chain
- [ ] Branch-switching deserialization failure test
- [ ] `StoreSource<OpenbaoSecretStore>` integration validated (compiles, graceful fallback)
- [ ] ADR for Zitadel OIDC target architecture