cf_configuration

package module
v0.0.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

caerus-framework-configuration

CI codecov License

Caerus Framework — configuration component.

Per-component, strongly-typed configuration with validated hot-reload. Each component registers its own config source and reads the current value through a typed accessor. Load order (later wins):

code zero value → file (optional) → env overlay (EnvPrefix)
→ flag overlay (flag tags) → AfterLoad → Validate

Files are the Kubernetes rotation plane (External Secrets → mount → fsnotify). Env overlays support local/CI/PaaS; fileless sources are allowed when EnvPrefix is set. Flags are a process-start overlay for the same fields (ParseFlags) — not a second config system; unknown flags and positional args are returned so subcommands survive. Values are swapped atomically after validation; rejected reloads keep the previous value.

Features

  • Per-component sources: every source is generic over its config type — no shared global config struct, no map[string]interface{} reads.
  • Fail-fast startup: AddSource builds + validates immediately and returns the error; a broken config never starts.
  • Env overlay: EnvPrefix maps PREFIX + env/json field names onto the struct after the file decode (or onto a zero value when Path is empty).
  • Flag overlay: flag-tagged fields get a --<flag> via ParseFlags, applied after env and before AfterLoad. Long names only (stdlib flag).
  • Scalar and pointer fields: both are first-class. Prefer scalars when zero-value-is-default. Use *T only when absent must differ from an explicit value (non-zero code defaults, meaningful zero, reload presence). Env/flag overlays allocate pointer fields on first set; omitted keys stay nil. Do not pointer-wrap every setting for uniformity.
  • AfterLoad: hook for DSN/URL overlays (e.g. POSTGRES_DSN, VALKEY_URL) before Validate.
  • Validated hot-reload: fsnotify watches the file's directory. On change the file is re-read, env/AfterLoad reapplied, re-validated, then swapped.
  • Forced reload: Reload(name) / ReloadAll() re-apply env+AfterLoad even when file bytes are unchanged (see “Detecting env changes” below).
  • Reload dispatch: after a validated swap, the source's owner component (by name) gets OnConfigReload(source, cfg) with the fresh value if it implements cf.ConfigReloader. The owner is also notified once at Init with the source's initial value, and immediately from AddSource when the component is already initialized — so logs/observability (which cannot import this module) boot on defaults and then receive their real config.
  • Kubernetes-safe: watches the parent directory and re-stats the target, so ConfigMap/Secret symlink swaps are detected; identical content is deduplicated by hash (no spurious reloads). See docs/K8S.md.
  • Secret fields: tag credentials secret:"redact". Overlay and Get still hold the real value. LogArgs / SecretPresence are the only helpers that look at the tag — use them on reload summaries instead of logging the struct.

Secret fields (secret:"redact")

Configuration declares which fields are secrets. Logs prints [redacted]. Pick this one tag; do not invent a second convention.

type PostgresConfig struct {
    Host     string `json:"host" env:"HOST"`
    Password string `json:"password" env:"PASSWORD" secret:"redact"`
}
Wrong: slog.Info("reload", "cfg", cfg)          // dumps password
Right: slog.Info("reload", LogArgs(cfg)...)     // password=[redacted], password_set=true, host visible
Right: slog.Info("reload", "host", cfg.Host, SecretPresence(cfg)...)

Get / Lookup / env / flags are unchanged. Empty secrets log password_set=false and do not print [redacted]. Nested structs are not walked (same limit as env overlay). First consumers: postgresql password, valkey password, resend api_key.

Do not log overlay parse errors at Info with file bytes. Reload failures stay at Error with the parse error (JSON/YAML messages, not a dump of the file).

Usage

Configuration is always-on core: cf.New(&cf.FrameworkOptions{…}) registers logs → configuration → observability. main does not construct cf_configuration.New() or call ParseFlags — components own their sources (WithConfigSource / cf.ConfigSourceRegistrar), and the framework absorbs argv (registrar pass → ParseFlags) before Initialize / Run.

Golden path (same shape as caerus-framework-demoapp):

package main

import (
	"context"
	"log"
	"time"

	cf "github.com/caerus-framework/caerus-framework"
	cf_postgres "github.com/caerus-framework/caerus-framework-postgresql"
	cf_valkey "github.com/caerus-framework/caerus-framework-valkey"

	"example.com/myapp/internal/app"
)

func main() {
	fw := cf.New(&cf.FrameworkOptions{
		Logs: &cf.LogsSettings{
			Format:       "json",
			Level:        "info",
			ConfigSource: "logs", // core Source[LogConfig]; file config/logs.json
		},
		Observability: &cf.ObservabilitySettings{
			Bind:         ":9090",
			ConfigSource: "observability",
		},
		Components: []cf.CaerusComponent{
			// Module registers Source[PostgresConfig] itself (name, path, env, job).
			cf_postgres.New(
				cf_postgres.WithConfigSource("postgresql", "config/postgresql.json",
					cf_postgres.WithSourceEnvPrefix("POSTGRES_")),
				// Local only: WithMigrateOnInit(). Prod: --postgresql.job=migrate.
			),
			cf_valkey.New(
				cf_valkey.WithConfigSource("valkey", "config/valkey.json"),
			),
			app.New(app.Options{}), // may register a "demoapp" / app source the same way
		},
	})

	if err := fw.RunWithSignals(context.Background(),
		cf.WithShutdownTimeout(15*time.Second),
	); err != nil {
		log.Fatal(err)
	}
}

What the module does under WithConfigSource (you normally do not call this from main — stock chassis already do):

// Inside RegisterConfigSources / Init-time registrar (owner = c.Name()):
_ = cf_configuration.AddSource(cfg, cf_configuration.Source[cf_postgres.PostgresConfig]{
	Name:      "postgresql",
	Path:      "config/postgresql.json", // K8s-mounted file / symlink OK
	Format:    cf_configuration.FormatJSON,
	Owner:     c.Name(),
	EnvPrefix: "POSTGRES_",
	Job:       cf.JobSpec{Flag: "postgresql.job", Tasks: []string{"migrate"}},
	AfterLoad: func(c *cf_postgres.PostgresConfig) error {
		if dsn := os.Getenv("POSTGRES_DSN"); dsn != "" {
			return cf_postgres.OverlayDSN(c, dsn)
		}
		return nil
	},
	Validate: func(v *cf_postgres.PostgresConfig) error { /* … */ return nil },
})

Read the current value after the configuration stage has initialized (prefer Lookup / Get so a missing source is an error, not a panic):

func (c *CFPostgres) Init(ctx context.Context, fw *cf.CaerusFramework) error {
	cfg, err := cf_configuration.Lookup[cf_postgres.PostgresConfig](fwCfg, c.configSource)
	if err != nil {
		return err
	}
	_ = cfg
	return nil
}

Receive reloads by implementing cf.ConfigReloader on the owner. The configuration component delivers the fresh value as cfg any (type-assert it) plus the source name:

func (c *CFPostgres) OnConfigReload(source string, cfg any) {
	if source != c.configSource {
		return
	}
	typed, ok := cfg.(*cf_postgres.PostgresConfig)
	if !ok {
		c.logger.Error("cf_postgres: config reload rejected", "source", source)
		return
	}
	// build new pool → ping → swap under mutex → close old (last-good on failure)
	_ = typed
}

Note: Prefer Lookup (or Get) in Init/reload so misconfiguration returns an error. MustGet panics on a missing source — fine for main/tests, not for reload paths. It is safe to call Get / Lookup / MustGet from within OnConfigReload: the configuration component releases its internal lock before invoking the callback, so there is no risk of deadlock.

AddComponent / bare cf.New() without options remain valid for tests and embedded use; production services should follow the FrameworkOptions shape above.

Flag overlay (ParseFlags)

Flags are a process-start overlay over the exact same fields as env — not a second config system. A field gets a --<flag> (stdlib flag, long names only) when its flag struct tag is set; flag:"-" opts out, and an absent tag means no CLI for that field. Bool fields (including *bool) register as bare flags (--tls, --tls=false); other scalars and pointer-to-scalars take a value (--host db.internal or --host=db.internal). Pointer fields stay nil when the flag/env key is omitted. Every source with a Path additionally gets a --<Name> file-path flag (default = the source's Path).

Contract:

  • Register first, parse second: every AddSource must run before ParseFlags so flag definitions exist. The framework enforces that order (registrars → ParseFlags). ParseFlags re-loads all sources with the flag values applied, and the parsed map is kept and re-applied on every later Reload / ReloadAll (flags do not hot-reload on their own; they are process-start only).
  • Unique field flags: flag names are a process-wide namespace across all registered sources (including core logs / observability). The same --short-flag declared on two sources — or twice on one source — is a wiring error at ParseFlags, even when types match. Do not reuse short tags like flag:"host" across modules; pick distinct names (e.g. log-level, http-addr).
  • Per-source file-path flags: every source with a Path also gets a --<Name> flag (default = its Path). Providing it overrides where that source's file is read from — the file location is itself a per-source option. There is no "config directory" bootstrap setting; each source declares its own file, env and arg options.
  • Unknown flags and positional args survive: the first unknown flag, single-dash arg, positional arg, or -- terminator moves the rest of the command line to the returned rest untouched — so serve / migrate / app flags fall through to the app.
  • Layering: flags win over env, env wins over file, AfterLoad runs last (DSN/URL merges see the final value).
  • Job flags: a module declares a job on its source with Source.Job (cf.JobSpec{Flag, Tasks}). The flag names the instance and the value names the task to run on it (e.g. --postgresql.job=migrate). Jobs are CLI-only — the value never flows from env or file (the config struct carries no job field); ParseFlags registers --<Flag> as a string flag and JobRequests() (implements cf.JobSource) returns the parsed request(s) after argv absorption, validating the task against the declared Tasks (fail-fast before any data Init). Two job flags that name the same Owner (the same component Name()) are a JobRequests error: one job per target per process. A job flag colliding with a field flag or another source's job flag is a parse-time wiring error. Distinct Owners (for example --postgresql.job=migrate and --postgresql.orders.job=migrate on two named postgres instances) are two targets and both run.

In production binaries main never calls ParseFlags. The framework runs the registrar pass (every ConfigSourceRegistrar) then ParseFlags at the start of Initialize / Run / RunWithSignals. Leftover positionals / unknown flags are available as fw.LeftoverArgs() for the app. See demoapp cmd/demoapp/main.go for the full pattern.

Detecting env changes (with or without a file)

The process environment is not watchable (no inotify on environ). With a file present:

Trigger What happens
File bytes change (External Secrets, ConfigMap swap) Automatic: re-read file → re-apply current env → AfterLoad → notify owner
Env changes, file unchanged Nothing until something calls Reload / ReloadAll
Fileless source (EnvPrefix only) Same: use Reload after env changes

Recommended patterns:

  1. Kubernetes (preferred): put rotating secrets in mounted files; do not rely on env for rotation. File watch is the signal.
  2. Explicit refresh: call cfg.Reload("postgresql") from a SIGHUP handler, admin endpoint, or after a known env update in tests.
  3. Do not poll the environment in a tight loop.
// Example: SIGHUP re-applies env overlays and notifies ConfigReloaders.
go func() {
    for range sighupCh {
        _ = cfg.ReloadAll()
    }
}()

Component contract

Implements caerusframework.CaerusComponent:

  • Name()"configuration" (cf_configuration.ComponentName)
  • GetInitOrderStage()caerusframework.ConfigurationStage (second bootstrap stage, right after logs — so later components can read their config during Init)
  • GetDependencies()[logs]: the component logs through the framework logs component; the logger is re-delivered on logs Reconfigure. WithLogger(*slog.Logger) overrides the logger for tests/embedded use; without a logs component the fallback is slog.Default().
  • Init starts the watcher + reload loop; Shutdown stops them cleanly.
  • Implements cf.MetricsProvider: contributes a configuration_info sample (count + source names) to the observability component's /metrics; reports nothing before any source is registered (lazy pickup).

Hot-reload semantics

Situation Behaviour
Initial load failure (missing file, bad parse, validation error) AddSource returns an error; source not registered; startup continues to fail via the caller
Valid change detected New value swapped in atomically; owner OnConfigReload(source, cfg) called
Malformed content on reload Rejected; previous value kept; error logged
Validator rejects new value on reload Rejected; previous value kept; error logged
Content unchanged (e.g. K8s rewrites identical bytes) Skipped (sha256 dedup); no reload, no notification
Multiple configs in one directory Any event re-checks affected sources; hash dedup keeps it cheap and correct

Docs

  • docs/K8S.md — running on Kubernetes: ConfigMap/Secret mounts, symlink swaps, and what the watcher does about them.
  • ARCHITECTURE.md — component model and stage ordering.

License

Apache License 2.0 — see LICENSE.

Documentation

Index

Constants

View Source
const (
	// SecretTag is the struct-tag key.
	SecretTag = "secret"
	// SecretRedact is the only supported tag value: print [redacted] / presence,
	// never the cleartext, when using LogArgs / SecretPresence.
	SecretRedact = "redact"
)

Secret tag convention (this module owns the name; logs owns the appearance).

Password string `json:"password" env:"PASSWORD" secret:"redact"`

Overlay, Get, and Lookup are unchanged: the live value still holds the password. Only helpers in this file look at the tag, for logs and tests.

View Source
const ComponentName = "configuration"

ComponentName is the framework component name for the configuration component. It is the identifier other components use in GetDependencies to require configuration.

Variables

This section is empty.

Functions

func AddSource

func AddSource[T any](c *Configuration, src Source[T]) error

AddSource registers and loads a configuration source on the given component. The value is built immediately (fail-fast); the source is not registered on failure. Reloads never fail the process: a rejected reload keeps the previous value. AddSource is safe to call before or after Init.

Path and/or EnvPrefix must be set. Format is required when Path is set.

func Get

func Get[T any](c *Configuration, name string) (*T, bool)

Get returns the current value of the named source on the given component, typed as *T. It reports false if the source does not exist or was registered with a different type. The returned pointer is atomically swapped on reload: readers always observe either the previous or the new value, never a partial write.

func LogArgs added in v0.0.8

func LogArgs(cfg any) []any

LogArgs returns slog key/value pairs for a config struct (or pointer). Top-level exported fields only (same limit as env/flag overlay).

  • Unmarked scalars stay visible (host, port, …).
  • Fields tagged `secret:"redact"` become RedactedString plus `<json>_set`.
  • Nested structs are skipped. Do not slog.Any the raw struct instead.

func Lookup

func Lookup[T any](c *Configuration, name string) (*T, error)

Lookup returns the current value of the named source on the given component, typed as *T, or an error if it does not exist or was registered with a different type. Prefer Lookup (or Get) from Init and OnConfigReload so misconfiguration surfaces as error rather than panic.

func MustGet

func MustGet[T any](c *Configuration, name string) *T

MustGet returns the current value of the named source on the given component, typed as *T, or panics if it does not exist or was registered with a different type. Prefer Lookup in Init/reload; MustGet is crash-fast sugar for main and tests where a missing source is a programmer error.

func SecretPresence added in v0.0.8

func SecretPresence(cfg any) []any

SecretPresence returns only `<json>_set` bools for `secret:"redact"` string fields. Use on reload summaries when you already log host/port yourself.

Types

type Configuration

type Configuration struct {
	// contains filtered or unexported fields
}

Configuration is the caerus-framework-configuration component. It owns a set of per-component configuration sources: each is loaded exactly once, watched for changes, and swapped atomically on a validated reload.

func New

func New(opts ...Option) *Configuration

New creates a configuration component. Add sources with AddSource (from any component's Init) and read the current value with Get/MustGet.

func (*Configuration) AddSourceValue

func (c *Configuration) AddSourceValue(src cf.ConfigSourceValue) error

AddSourceValue registers a configuration source from its generic-free declaration (cf.ConfigSourceValue). It is the cycle-free entry point for core modules (logs, observability) that the configuration module imports: the framework hands them the component as cf.ConfigSourceAdder and they call this with their own declaration. Sample's dynamic type selects the concrete config struct and decoding, exactly as Source[T].T would.

func (*Configuration) GetDependencies

func (c *Configuration) GetDependencies() []string

GetDependencies implements cf.Dependencies. The component logs through the framework logs component.

func (*Configuration) GetInitOrderStage

func (c *Configuration) GetInitOrderStage() cf.Stage

GetInitOrderStage implements cf.CaerusComponent. Configuration runs in the second bootstrap stage, right after logs, so later components can read their config during Init.

func (*Configuration) Init

Init implements cf.CaerusComponent. It starts the file watcher and the reload loop, and begins watching every source registered so far. Sources registered later (during other components' Init) are watched immediately.

After starting the watcher it notifies each source's owner with the already-loaded value. The core components initialize before configuration (logs) or without a Lookup path (logs again) — the initial notification is how they receive their configuration after booting on defaults.

func (*Configuration) JobRequests

func (c *Configuration) JobRequests() ([]cf.JobRequest, error)

JobRequests implements cf.JobSource. It inspects every registered source's declared job flag (the flag must have been parsed by ParseFlags) and reports the requested jobs: the flag names the instance (the source's Owner), the value names the task to run on it (e.g. --postgresql.job=migrate → run task "migrate" on the "postgresql" instance). A task outside the source's declared Tasks set is an error. Two flags that name the same Owner are an error: one job per target per process. CLI-only: file and environment values never produce a job request. Empty (no job flag provided) returns an empty slice.

JobRequests must run after argv absorption; the framework calls it before any component initializes. Sources are visited in registration order so a duplicate-Owner error names a stable pair of flags.

func (*Configuration) Name

func (c *Configuration) Name() string

Name implements cf.CaerusComponent.

func (*Configuration) ParseFlags

func (c *Configuration) ParseFlags(args []string) (rest []string, err error)

ParseFlags registers --<flag> for every currently registered source's flag-tagged fields and a --<source-name> file-path flag for every source with a Path, parses args, and re-applies the resulting values across all sources (flags win over env; env wins over file). The file-path flags override where each source's config file is read from; defaults are the sources' current paths, so an absent flag is a no-op.

Flags are a process-start overlay: the parsed field map is kept and re-applied on every subsequent Reload / ReloadAll; a path override persists on the source itself (reloads and the file watcher follow it).

Register all AddSource calls first so the flag definitions exist. Unknown flags and positional args are returned untouched — subcommands (`serve`, `migrate`) and app flags fall through to the caller.

func (*Configuration) Reload

func (c *Configuration) Reload(name string) error

Reload forces a re-load of the named source, reapplying env overlay and AfterLoad even when the file bytes are unchanged. Use this after an external process env change (for example from a SIGHUP handler). The process environment is not watchable; without Reload (or a file change), new env values are invisible. Returns an error if the source is unknown or the load is rejected (previous value kept). Notifies the owner on success when the effective value changed.

func (*Configuration) ReloadAll

func (c *Configuration) ReloadAll() error

ReloadAll forces Reload on every registered source. Owners are notified after all loads complete. The first load error is returned; later sources still run.

func (*Configuration) Shutdown

func (c *Configuration) Shutdown(ctx context.Context) error

Shutdown implements cf.CaerusComponent. It stops the watcher and waits for the reload loop to exit. Safe to call even if Init never ran.

func (*Configuration) Sources

func (c *Configuration) Sources() []string

Sources returns the names of registered configuration sources in sorted order. Returns nil when no sources are registered.

type Format

type Format int

Format selects the on-disk encoding of a configuration file.

const (
	// FormatJSON parses JSON files with encoding/json.
	FormatJSON Format = iota
	// FormatYAML parses YAML files with gopkg.in/yaml.v3.
	FormatYAML
)

type Option

type Option func(*options)

Option configures the configuration component at construction time.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger overrides the logger used for reload/watcher diagnostics. By default the component logs through the framework logs component (declared in GetDependencies); WithLogger is an explicit override for tests and embedded use and wins over the framework logger. slog.Default() remains the fallback only when neither is available.

type Source

type Source[T any] struct {
	// Name is the logical name of this source (e.g. "mongodb"). It is the key
	// used by Get/MustGet and must be unique within the framework.
	Name string
	// Path is the configuration file path. It may be a symlink (as with
	// Kubernetes ConfigMap/Secret mounts), so the directory is watched and the
	// target is re-stat'ed on every event; see docs/K8S.md. Empty is allowed
	// when EnvPrefix is set (fileless / env-only source).
	//
	// A source with a Path also gets a --<Name> file-path flag in ParseFlags:
	// providing it overrides where the file is read from (defaults to this
	// Path), so the file location is itself a per-source CLI option. There is
	// no "config directory" bootstrap setting — each source declares its own
	// file, env and arg options.
	Path string
	// Format selects the file encoding. Ignored when Path is empty.
	Format Format
	// Owner is the Name of the component that consumes this configuration. On a
	// validated reload, the owner's OnConfigReload (if it implements
	// cf.ConfigReloader) is invoked. Empty disables reload dispatch.
	Owner string
	// EnvPrefix, when non-empty, overlays matching environment variables onto
	// the decoded value after the file is read (or onto a zero value when Path
	// is empty). Keys are EnvPrefix + `env` tag, or UPPER_SNAKE of the json
	// name. Example: EnvPrefix "POSTGRES_" and field `Host` → POSTGRES_HOST.
	EnvPrefix string
	// Job, when declared, registers a CLI-only job flag for this source's Owner.
	// The flag names the instance and the value names the task to run on it
	// (e.g. --postgresql.job=migrate); the framework reads the request via
	// cf.JobSource after argv absorption and routes it before serving. CLI-only:
	// the value lives in the parsed flag, never in the config file or
	// environment. Tasks (if non-empty) restricts the accepted task values;
	// a value outside the set fails JobRequests. Two sources must not set a
	// job flag for the same Owner in one process (JobRequests fails closed:
	// one job per target). The source must set Owner.
	Job cf.JobSpec
	// AfterLoad runs after file+env overlay and before Validate. Use it for
	// DSN/URL overlays (e.g. POSTGRES_DSN → OverlayDSN). Nil skips the step.
	AfterLoad func(*T) error
	// Validate runs after every successful load (initial and reload). It must
	// return nil for the new value to be accepted. On reload, a rejected value
	// keeps the previous one in effect.
	Validate func(*T) error
}

Source describes one configuration source and how to interpret it. It is generic over the concrete config type, so each component gets its own strongly-typed config with no shared global struct.

Load order (later wins): file (if Path set) → env overlay (if EnvPrefix set) → flag overlay (if ParseFlags ran and the struct has flag tags) → AfterLoad → Validate. Files are the Kubernetes rotation plane (External Secrets → mount → fsnotify); env is for local/CI/PaaS; flags are a process-start overlay and do not hot-reload by themselves.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL