Lifecycle Management¶
Confii provides thread-safe lifecycle operations for reloading, extending, freezing, overriding, mutating, and observing runtime configuration.
Runtime I/O is context-controlled and Reload/Extend prepare private candidates without blocking readers. See Context, cancellation, and operation lifecycles.
Admission before resolution¶
Materialization admits what it can before contacting any provider:
load sources -> parse -> merge
-> admit secret-reference syntax <- no provider contacted yet
-> resolve secrets <- providers initialized here
-> validate (schema, types, validators)
-> publish
A reference-shaped token the grammar cannot parse fails here, naming the
configuration path and the offending locator. Nothing is fetched. This holds for
construction, Reload and Extend alike, and a failed admission leaves the
previous configuration in place.
Why some admission stays after resolution¶
Two checks deliberately run later, because moving them earlier would reject valid configurations:
Type and schema admission. A field typed int holding ${secret:db/port}
carries a string until the secret resolves. Schema constraints and exact-type
admission describe the resolved value, not the placeholder standing in for it.
Sensitivity classification. It is derived from the unresolved configuration either way, so the result is identical; only the assignment point differs. A failed reload rolls the configuration back without restoring the classification, so assigning it from a candidate that never became live would leave the wrong one behind.
Why routing is not admitted¶
When you supply your own resolver, routing belongs to it. A custom
ManagedSecretResolver may carry its own provider registry, so refusing an alias
Confii does not recognize would break it. The bundled single-store resolver
reports ErrProviderRoutingUnsupported at resolution instead.
Reload¶
Reload re-reads configuration sources and builds a private, fully materialized candidate. Confii publishes it atomically only after loading and validation succeed. If a source fails under ErrorPolicyRaise, the candidate is discarded and readers continue to observe the previous snapshot.
Only reload files whose mtime or SHA256 content hash has changed. This avoids unnecessary parsing when most files have not been modified.
Load and validate from sources without applying any changes. Useful for pre-flight checks in CI or before a deploy.
Combine reload options
Multiple reload options may be combined in one call:
Frozen configs cannot reload
Calling Reload on a frozen config returns ErrConfigFrozen. Unfreeze first or use Override for temporary changes.
Extend¶
Add a new loader at runtime and merge its configuration on top of the existing state. The new loader is also registered for future reloads.
err := cfg.ExtendWithContext(ctx, loader.NewJSON("extra.json"))
if err != nil {
log.Fatal(err)
}
// The new source is now part of the config
val, _ := cfg.Get("extra.key")
Extend vs Reload
Extend adds a new source and merges it immediately. Reload re-reads all existing sources. After Extend, the new loader is included in subsequent reloads.
Override¶
Apply temporary scoped overrides. Returns a restore function that reverts to the original state. This is especially useful in tests.
restore, err := cfg.Override(map[string]any{
"database.host": "test-db",
"database.port": 15432,
})
if err != nil {
log.Fatal(err)
}
defer restore() // always restore when done
host, _ := cfg.Get("database.host") // "test-db"
Test-friendly pattern
Override temporarily unfreezes the config, applies changes, then the restore function re-freezes it back to its original state.
Freeze¶
Make the configuration immutable. Any mutation attempt (Set, Reload, Extend, RollbackToVersion) returns ErrConfigFrozen.
cfg.Freeze()
err := cfg.Set("key", "value")
// err wraps ErrConfigFrozen
fmt.Println(cfg.IsFrozen()) // true
Configuration may also be frozen during construction:
ErrConfigFrozen
Use errors.Is(err, confii.ErrConfigFrozen) to check for frozen state errors:
Set¶
Set a value by dot-separated key path. Thread-safe and respects frozen state.
Protected Set¶
Use WithOverride(false) to prevent overwriting an existing key. This is useful for setting defaults without clobbering user-supplied values.
// Only set if "app.name" does not already exist
err := cfg.Set("app.name", "default-name", confii.WithOverride(false))
if err != nil {
// key already exists
log.Println(err)
}
Set invalidates the typed model cache
After Set, the next call to cfg.Typed() will re-decode and re-validate the config.
OnChange¶
Register callbacks that fire when configuration values change after a reload. Callbacks receive the key path, old value, and new value.
cfg.OnChange(func(key string, oldVal, newVal any) {
log.Printf("config changed: %s = %v -> %v", key, oldVal, newVal)
})
cfg.OnChange(func(key string, oldVal, newVal any) {
if key == "log.level" {
updateLogLevel(newVal.(string))
}
})
Multiple callbacks
Callbacks run in registration order for each changed key. Callback panics are recovered and do not propagate.
When do callbacks fire?
Callbacks fire after successful Set, Override, override restoration,
Reload, Extend, secret refresh, and version rollback commits. Dry runs,
rejected candidates, and no-op operations do not deliver changes.
Full Lifecycle Example¶
package main
import (
"context"
"errors"
"fmt"
"log"
confii "github.com/confiify/confii-go/v2"
"github.com/confiify/confii-go/v2/loader"
)
func main() {
ctx := context.Background()
// Create config
cfg, err := confii.NewWithContext[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
confii.WithEnv("production"),
)
if err != nil {
log.Fatal(err)
}
// Register change callback
cfg.OnChange(func(key string, oldVal, newVal any) {
fmt.Printf("changed: %s\n", key)
})
// Extend with another source
_ = cfg.ExtendWithContext(ctx, loader.NewJSON("overrides.json"))
// Set a value with protection
_ = cfg.Set("feature.enabled", true, confii.WithOverride(false))
// Temporary override for testing
restore, _ := cfg.Override(map[string]any{"database.host": "test-db"})
fmt.Println(cfg.GetStringOr("database.host", "")) // "test-db"
restore()
// Reload with dry-run first
if err := cfg.ReloadWithContext(ctx, confii.WithDryRun(true)); err != nil {
log.Printf("dry-run failed: %v", err)
} else {
_ = cfg.ReloadWithContext(ctx) // apply for real
}
// Freeze when done
cfg.Freeze()
if err := cfg.Set("key", "val"); errors.Is(err, confii.ErrConfigFrozen) {
fmt.Println("config is frozen")
}
}