Overview¶
- Status: ✅ Accepted
- Audience: Contributors to flexconf and developers integrating the SDK.
This spec describes the overall architecture of flexconf, the core concepts, and the vocabulary used across all other specs. It is intentionally high-level; details belong in the topic-specific specs linked below.
1. Goal¶
flexconf lets a Go application:
- Declare the shape of its configuration once, as Go types.
- Load that configuration from one or more config directories with sensible layering.
- Resolve templating tokens in config values transparently — pulling from the environment or from secret backends — without the application code needing to know which values are secrets.
The distinguishing idea: the config author, not the application developer,
decides what is a secret. A developer says "I need a field Token string".
An operator writing the config file writes
token: $(secret:artifactory/token) — or token: hardcoded-for-dev in a
throwaway environment. The application is unchanged either way.
2. Core concepts¶
Config schema¶
The application-provided description of its configuration, expressed as Go
types (structs with tags). flexconf binds loaded values into an instance of
these types. The flexconf:"..." struct tag mirrors the semantics of the
standard yaml:"..." tag (field name, then comma-separated options); it exists
as a format-agnostic abstraction so the same struct can back other config
formats later without re-tagging. Details: schema-and-binding.md.
type Config struct {
Service string `flexconf:"service"`
Timeout time.Duration `flexconf:"timeout,default=30s"`
Artifactory struct {
URL string `flexconf:"url"`
Token string `flexconf:"token"` // may resolve from a secret, but the type doesn't care
} `flexconf:"artifactory"`
}
Config directory / layer¶
Configuration is loaded from one or more config directories. A Loader is configured with an ordered list of them; each directory is a layer. When more than one directory provides the same file, the layers are merged into a single tree (a later directory overrides an earlier one, per-key). A single directory is the common case; multiple directories enable base/override layering (e.g. a shipped default dir beneath a site-local one). Details: config-loading.md.
Config file¶
Within the directories, an individual configuration is selected by file name
— config.yaml, agents.yaml, … — and bound into a Go struct. One Loader can
load several kinds of config for the same app by name (see Loader). Environment
variables are not a config layer; they feed $(env:...) tokens (see
Resolver), not the directory merge. Details: config-loading.md.
Templating token¶
A placeholder embedded in a config value with the grammar
$(scheme:path), e.g. $(env:HOME), $(secret:artifactory/token). Tokens are
resolved after layers are merged and before binding. Details: templating.md.
Resolver¶
The component that resolves a token for a given scheme. env and secret
are built in; custom schemes can be registered. A resolver maps a path to a
concrete value. Details: resolvers.md.
Vault driver¶
The pluggable backend behind the secret: scheme. A driver knows how to fetch
a secret by path from a specific system (HashiCorp Vault, AWS/GCP/Azure secret
managers, an encrypted file, …). The active driver is selected at runtime
via a driver-registry pattern, so swapping backends requires no code change in
the loading path. Details: vault-drivers.md.
Loader¶
The top-level orchestrator, configured with the ordered config directories. Its
Load(name, &dst) method ties everything together for one named file: read that
file from each layer → merge by precedence → resolve tokens (via resolvers,
which may call vault drivers) → bind into dst → validate. The same Loader
loads several config kinds by calling Load with different names and structs
(Load("config.yaml", &cfg), Load("agents.yaml", &agents)). Details:
config-loading.md and api.md.
3. How the pieces fit together¶
Load("config.yaml", &cfg)
┌─────────────────────────────────────────────────┐
config dirs ─────▶ │ Loader │
(ordered layers) │ │
│ 1. read <name> from each layer (directory) │
│ 2. merge by precedence → raw value tree │
│ 3. resolve $(scheme:path) tokens ───────────────┼──▶ Resolvers
│ • env: → environment │ │
│ • secret: → Vault Driver ──────────────────┼──▶ Vault / AWS / file / …
│ • custom: → registered resolver │ │
│ 4. bind resolved tree → &cfg (the struct) │◀───────┘
│ 5. validate (required, types, custom rules) │
└────────────────────────┬────────────────────────┘
▼
typed, validated config value
Resolution order matters: tokens are resolved on the merged tree, so a value introduced by a higher-precedence layer can itself contain a token. Nesting and escaping rules are defined in templating.md.
4. Token grammar summary¶
Normative grammar lives in templating.md; summarized here for orientation.
- A token has the form
$(scheme:path). schemeselects a resolver;pathis opaque to flexconf and interpreted by that resolver.- Built-in schemes:
env:NAME— value of environment variableNAME. A missing variable is a hard error; there is no:-defaultsyntax (templating.md §10).secret:[vault:]namespace/key— value fetched for that two-level secret address (see vault-drivers.md §6). An optional leadingvault:segment selects a named vault from the registry; omitting it uses the default vault (see vault-registry.md §4–§5).file:path— verbatim contents of a file (relative to the containing config file); non-secret (see resolvers.md §4).config:path.yaml— structural include: splices another YAML file's tree in as the whole value (no deep-merge); a different composition axis from directory layering (templating.md §7).- Scalar tokens (
env/secret/file) may appear anywhere inside a string value and be combined with literal text:url: https://$(env:HOST):$(env:PORT)/api.config:must be a whole value. Templating operates on the parsed node tree (values only, never keys; resolved values are inert), and a whole-value token is left untagged so its type is inferred from the resolved content (templating.md §2, §6).
5. Vault driver selection summary¶
Normative rules live in vault-drivers.md.
- Drivers implement a common interface and register under a name.
- Vaults are named in a registry (
map[name]VaultConf) and referenced by name; each name maps to a driver + its non-secret config. Definitions are layered across files and code, with a designated default vault. Details: vault-registry.md. - The
secret:resolver delegates to whichever vault the token selects (the named one, else the default). Config authors never name the backend in tokens — only an optional logical vault name and the secret address ([vault:]namespace/key).
6. Module layout¶
The module (go.mod root github.com/sylvanld/go-flexconf) is organized around three core packages plus
an optional CLI package. The split follows the dependency direction —
flexprompt at the bottom — so there are no import cycles.
| Package | Owns | Imports |
|---|---|---|
flexprompt |
Credential/input collection: the Prompter interface, PromptRequest, the process-wide singleton (SetPrompter/GetPrompter), built-in prompters (NewCLIPrompter, NewMapPrompter, NewEnvPrompter), and prompt errors. The module's leaf package. |
(stdlib only) |
flexvault |
Secret backends and their lifecycle: the VaultDriver interface, the Manager (unlock/get/set/list), Capabilities, the sentinel errors, driver registration (Register/New), the config decoders (MapDecoder/EnvDecoder), and concrete drivers under flexvault/driver/* (e.g. flexvault/driver/keepass). |
flexprompt |
flexconf |
Config loading: schema declaration and binding, config directories and layering, the templating engine, resolvers (env, secret, file), and the top-level Loader. The secret: resolver reaches vaults through the background agent (an internal proxy over internal/agent), driving a flexvault.Manager (resolvers.md §5). Re-exports RunAgentIfRequested. |
flexvault, flexprompt, internal/agent |
internal/agent (internal) |
The secret agent runtime: the socket protocol, client (Dial), server loop (Serve), self-exec spawning (RunAgentIfRequested), VaultID derivation, socket/PID/lock-file handling, idle auto-lock, and the internal agent-proxy VaultDriver. Holds one unlocked vault in memory (ssh-agent style). Not part of the public API; shared by flexconf and flexcli (resolvers.md §5.5). |
flexvault, flexprompt |
flexcli (optional) |
A mountable Cobra secret command group (init/unlock/lock/get/set/list) that drives the internal/agent runtime. Mounted embedded in an app's CLI or via the shipped cmd/flexconf binary; both read vault definitions from the vault registry (vault-registry.md). Not needed for programmatic use. |
flexvault, flexprompt, internal/agent, Cobra |
Rules:
- Dependencies point downward only:
cmd/flexconf → flexcli → internal/agent → flexvault → flexprompt, andflexconf → internal/agent(for thesecret:resolver).flexpromptMUST NOT import the others;flexvaultMUST NOT importflexconf,flexcli, orinternal/agent;internal/agentMUST NOT importflexconforflexcli; nothing importsflexcli. The agent runtime is a module-internal package so neitherflexconfnorflexclineeds to import the other to share it (resolvers.md §5.5). - An application using only secret management (no config loading) can depend on
flexvault(+flexprompt) alone, without pulling in the config loader or Cobra. - Concrete drivers live in their own sub-packages (
flexvault/driver/keepass) and register withflexvaultfrominit(), so importing a driver for its side effect (_ "…/flexvault/driver/keepass") is all that's needed to make it selectable by name.
Details of the flexvault types are in vault-drivers.md;
the flexprompt Prompter in prompter.md; flexcli in
cli.md.
7. Glossary¶
| Term | Meaning |
|---|---|
| Config schema | App-provided Go types describing the expected configuration. |
| Config directory (layer) | A directory holding config files; the Loader is configured with an ordered list of them, each a layer merged by precedence. |
| Config file | An individual config selected by name (config.yaml, agents.yaml) within the layers and bound into a struct. |
| Layering | Merging the same-named file across config directories by precedence into one tree (later directory overrides earlier). |
| Token | $(scheme:path) placeholder inside a config value. |
| Scheme | The prefix of a token selecting a resolver (env, secret, …). |
| Resolver | Component that turns a token's path into a value. |
| Vault driver | Pluggable backend behind the secret: scheme (flexvault.VaultDriver). Formerly "secret driver". See vault-drivers.md. |
| Manager | flexvault.Manager: wraps one vault driver; drives its lifecycle, owns the credential dispatch, and exposes Unlock/Get/Set/List. See vault-drivers.md. |
| Prompter | Abstraction for collecting credentials in one interaction (CLI, GUI, env, tests). Lives in the flexprompt package as a process-wide singleton (SetPrompter/GetPrompter); the Manager passes it the driver's declared requests. See prompter.md. |
| Vault config vs. vault secret | A vault's non-secret settings (path, URL) vs. its credentials (password, token) — sourced and handled separately. See vault-drivers.md §2.1. |
| Secret address | A two-level namespace/key identifier for a secret (e.g. artifactory/token). See vault-drivers.md §6. |
| Vault registry | A layered map of vault name → VaultConf (driver + non-secret config) that names and configures vaults. See vault-registry.md. |
| Default vault | The vault a $(secret:...) token resolves against when no vault: segment is given; set by the registry's default: key. See vault-registry.md §4. |
| Vault reference | The optional vault: segment in a token ($(secret:vault:namespace/key)) selecting a named vault. See vault-registry.md §5. |
| Loader | Orchestrator configured with ordered config dirs; Load(name, &dst): read → merge → resolve → bind → validate for that named file. |
| Binding | Mapping the resolved value tree onto the config schema types. |
| Secret agent | Long-lived, ssh-agent-style background process (spawned by flexcli) that holds one unlocked vault in memory and serves get/set/list over a private socket, auto-locking after an idle timeout. See cli.md. |
| Global vault | A user-level vault defined in the user's registry (~/.config/flexconf/vaults.yaml), independent of any app. See vault-registry.md §2 and cli.md §5. |
8. Resolved design decisions¶
These were tracked here while open; they are now settled for v1 and recorded in the topic specs. Kept here as a pointer so the whole picture is visible.
- Resolution is eager, at load, only. Every token (including
secret:) is resolved duringLoad; there is no lazy/on-access or rotating-secret mode. This is a firm v1 decision, not a placeholder (resolvers.md §5, §9; config-loading.md §5). - No caching or TTL for vault drivers in v1. Each resolution asks the vault (through the running agent, or the in-process Manager); there is no freshness/TTL/invalidation layer. Explicitly out of scope for v1 (vault-drivers.md §11, resolvers.md §9).
- Behavior on partial failure — some tokens unresolved —
Loadfails loud and early (config-loading.md §3, §5).