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

7.6 KiB

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
    • PromptSourcestub (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.

// 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:

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:

#[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.

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.

#[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