configfilekv

package module
v0.2.8 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 10 Imported by: 0

README

config-filekv

A directory of single-value files as a config layer

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Documented with the parent module at config.go.phpboyscout.uk


Each file's name is a configuration key; its contents are the value.

/etc/config/
  database.host    →  db.internal
  database.port    →  5432
  log.level        →  debug

Three unrelated systems present configuration exactly this way, which is why this is named for the shape rather than for any one of them:

System Location
Kubernetes ConfigMap or Secret, mounted as a volume the mount path
Docker and Podman secrets /run/secrets/<name>
systemd LoadCredential $CREDENTIALS_DIRECTORY/<name>
import (
	"gitlab.com/phpboyscout/go/config"
	configfilekv "gitlab.com/phpboyscout/go/config-filekv"
)

backend, err := configfilekv.New(config.OS(), "/etc/config")
if err != nil {
	return err
}

store, err := config.NewStore(ctx,
	config.WithBackend(backend),
	config.WithFiles(fsys, "app.yaml"),
	config.WithEnv("MYAPP"),
)

Why not just read the directory yourself

A mounted ConfigMap does not contain plain files. The kubelet writes a timestamped directory and atomically repoints a ..data symlink at it, which is what makes an update impossible to observe half-applied:

..2026_07_29_10_00_00.1234/      real directory
..data  ->  ..2026_07_29_.../    repointed atomically on update
database.host -> ..data/database.host
log.level     -> ..data/log.level

List that naively and you get ..data and a timestamped directory alongside the real keys — two configuration keys invented out of the update mechanism.

Dot-prefixed entries are skipped, and skipped before recursion, so the whole staging tree stays invisible rather than being read twice.

What it does

  • Filenames split on .database.host nests as databasehost.
  • Subdirectories nest too, because a projected volume with an items[].path of sub/key produces exactly that.
  • Values are byte-exact. No trailing newline is trimmed unless you ask with WithTrimTrailingNewline(). All three systems write values exactly, so a stray newline only appears when a human used echo — and trimming by default would silently alter a value that legitimately ends in whitespace, which for a credential is an authentication failure with nothing to point at.
  • An empty file is an empty value, not an absent key.

Secrets

configfilekv.New(config.OS(), "/run/secrets",
	configfilekv.WithPrefix("secrets"),
	configfilekv.WithSensitive())

WithSensitive() marks the layer, so the core refuses to write one of these values into a layer that is not itself sensitive — the same protection a secrets manager gets, over a local directory.

Writing

Off by default, because every layout above is read-only: a ConfigMap volume is mounted read-only, Docker secrets are 0444, systemd credentials 0400. A plain directory is a perfectly good small writable store, so it is opt-in:

configfilekv.New(config.OS(), "/var/lib/myapp/config", configfilekv.WithWritable())

Worth knowing before you rely on it:

  • Writes are not atomic across keys. Three keys is three file writes; each is atomic in itself via write-then-rename, and rollback undoes a partial batch best-effort. There is no ..data trick available to a general directory — that is the kubelet's mechanism, not a filesystem primitive.
  • Conflict detection compares content, because a file has no version. It catches another writer, which is the case worth catching, but cannot tell "changed and changed back".
  • New files are 0600. A writable directory of single-value files may well hold credentials, and the looser default fails silently. WithFileMode widens it.
  • A value a single file cannot hold is refused rather than invented — a map has no one-file representation.

It adds no dependency

Everything it links comes from the config graph; it brings nothing of its own, and a test fails if that changes. That is much of the point: the alternative it replaces — a Kubernetes API client to read a ConfigMap the pod has already mounted — costs 38 modules, plus RBAC and a service account, and only works in-cluster.

Install

go get gitlab.com/phpboyscout/go/config-filekv

Requires config v0.12.0+, the release adding config.DirLister — the optional interface this needs to enumerate a directory at all.

Documentation

Full documentation lives with the parent module at config.go.phpboyscout.uk. The Go API reference is on pkg.go.dev.

Licence

MIT

Documentation

Overview

Package configfilekv reads a directory of single-value files as a configuration layer: each file's name is a key and its contents are the value.

Three unrelated systems present configuration exactly this way — a Kubernetes ConfigMap or Secret mounted as a volume, Docker and Podman secrets under /run/secrets, and systemd credentials under $CREDENTIALS_DIRECTORY — so this is named for the shape rather than for any one of them.

store, err := config.NewStore(ctx,
    config.WithBackend(configfilekv.New(config.OS(), "/etc/config")),
)

It adds no third-party dependency.

Why this is not a directory glob

A mounted ConfigMap does not contain plain files. The kubelet writes a timestamped directory and atomically repoints a "..data" symlink at it, which is what makes an update impossible to observe half-applied:

..2026_07_29_10_00_00.1234/      real directory
..data  ->  ..2026_07_29_.../    repointed atomically on update
database.host -> ..data/database.host

Listing that naively yields "..data" and a timestamped directory alongside the real keys, so a consumer who did not know would invent two configuration keys out of the update mechanism. Dot-prefixed entries are therefore skipped.

Index

Constants

View Source
const DefaultFileMode fs.FileMode = 0o600

DefaultFileMode is the mode a newly written file gets.

Owner-only, because a writable directory of single-value files may well be holding credentials. The looser default fails silently — nobody notices their secrets are world-readable — while this one fails visibly, as a sibling process unable to read what it expected.

View Source
const DefaultPollInterval = 30 * time.Second

DefaultPollInterval is how often the watcher re-examines the directory when the caller does not say.

View Source
const SourceKind = config.SourceKind("filekv")

SourceKind identifies layers this backend contributes.

Variables

View Source
var (
	// ErrNotListable is returned when the filesystem cannot enumerate a
	// directory, which this backend has no way to work without.
	//
	// Reported at construction rather than as an empty layer at load: a backend
	// whose whole job is enumeration silently contributing nothing is the worst
	// available outcome, because the configuration merely appears to be missing.
	ErrNotListable = errors.NewSentinel("config-filekv.not_listable", "configfilekv: filesystem cannot list directories")

	// ErrUnwritableValue is returned when a write carries something a single
	// file cannot hold — a map or a slice, which has no one-file representation.
	ErrUnwritableValue = errors.NewSentinel("config-filekv.unwritable_value", "configfilekv: value cannot be written to a single file")
)

Errors this backend returns. Callers should branch on these with errors.Is.

Functions

func New

func New(fsys config.FS, dir string, opts ...Option) (config.Backend, error)

New reads dir as a configuration layer.

The filesystem must implement config.DirLister; one that cannot enumerate returns ErrNotListable here rather than contributing nothing later.

Types

type Option

type Option func(*backend)

Option configures a backend.

func WithFileMode

func WithFileMode(mode fs.FileMode) Option

WithFileMode sets the mode of files this backend creates.

Defaults to DefaultFileMode, which is owner-only.

func WithPollInterval

func WithPollInterval(d time.Duration) Option

WithPollInterval overrides how often the watcher re-examines the directory.

func WithPrefix

func WithPrefix(prefix string) Option

WithPrefix nests every key under a path.

Without it the directory's keys sit at the root, which is right for a ConfigMap that *is* the configuration and wrong for a secrets directory sharing a store with everything else.

func WithSensitive

func WithSensitive() Option

WithSensitive marks the layer as holding secret material.

Two of the three systems this serves deliver secrets. Marking the layer means the core refuses to write one of these values into a layer that is not itself sensitive — the same protection a secrets manager gets, over a local directory.

Off by default, because a mounted ConfigMap usually is not secret and marking it so would refuse ordinary writes to the file beneath it.

func WithTrimTrailingNewline

func WithTrimTrailingNewline() Option

WithTrimTrailingNewline strips one trailing newline from each value.

Off by default, and deliberately so. Kubernetes, Docker and systemd all write a value byte-exact, so a trailing newline only appears when a human created the file with echo. Trimming by default would silently alter a value that legitimately ends in whitespace — and when that value is a credential, the result is an authentication failure with nothing to point at. A stray newline shows up in the first log line and can be diagnosed; a mangled secret cannot.

func WithValueCodec

func WithValueCodec(codec config.Codec) Option

WithValueCodec decodes each file's contents through a codec.

For a directory whose files hold JSON or YAML rather than scalars. A value that decodes to a mapping becomes a subtree; anything else falls back to the string, so a directory mixing the two works.

func WithWritable

func WithWritable() Option

WithWritable allows changes to be written into the directory.

Off by default because every layout this adapter was built for is read-only — a ConfigMap volume is mounted read-only, Docker secrets are 0444, systemd credentials 0400 — so a writable default would fail on the filesystem for all three real consumers.

A plain directory of files is a perfectly good small writable store, which is why the option exists at all.

Jump to

Keyboard shortcuts

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