178 lines
7.6 KiB
Markdown
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
|