Skip to content

Read config from an io/fs filesystem

Configuration compiled into the binary with embed, or read from a zip, a tar, or any other io/fs.FS, becomes a read-only config layer through the config-iofs sibling module.

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

The most common use is defaults baked into the binary as a fallback beneath the real files:

import (
    "embed"

    "gitlab.com/phpboyscout/go/config"
    configiofs "gitlab.com/phpboyscout/go/config-iofs"
)

//go:embed defaults.yaml
var embedded embed.FS

store, err := config.NewStore(ctx,
    config.WithFiles(configiofs.Wrap(embedded), "defaults.yaml"), // baked-in defaults
    config.WithFiles(config.OS(), "/etc/app.yaml"),               // real files outrank them
    config.WithEnv("APP"),
)
if err != nil {
    return err
}

format := store.View().GetString("log.format")

Wrap is the whole API. It takes any io/fs.FS (an embed.FS, os.DirFS, zip.Reader, a tar-derived filesystem, an fstest.MapFS in a test) and returns a config.FS.

Name files the io/fs way

Pass an io/fs-valid name: slash-separated, no leading slash, no . or .. segments (see fs.ValidPath). An invalid name is refused with an error wrapping fs.ErrInvalid, rather than read as a missing file, so a fat-fingered absolute path surfaces instead of silently resolving to "absent". A valid name that does not exist still returns the not-exist error the Store treats as a normal absent layer.

To root a sub-tree, compose fs.Sub before Wrap. It already works with no help from this module:

sub, _ := fs.Sub(embedded, "config")
store, _ := config.NewStore(ctx, config.WithFiles(configiofs.Wrap(sub), "app.yaml"))

Read-only, and where writes go

io/fs.FS is read-only by design: the interface has no write, rename or remove. So config-iofs is read-only too, and its four write methods return the shared sentinel config.ErrReadOnlyFS.

The Store cannot see that from the filesystem, so it only finds out when a write reaches the layer. A write to a key that is defined only in the iofs layer is routed there, because routing edits a key where it already lives. Plan accepts it and Apply fails:

_, err := store.Apply(ctx, config.Set("log.format", "text"))
if errors.Is(err, config.ErrReadOnlyFS) {
    // the key lives only in the embedded layer
}

To land such a write somewhere useful, name the writable layer with config.To. The embedded defaults tutorial walks through it. A layer that is only ever read, such as baked-in defaults, never triggers the error.

Watch for changes

An io/fs.FS has no operating-system path and no symlink resolution, so the wrapper satisfies neither config.RealPather nor config.LinkReader. The Store polls it, which is harmless for embed.FS: a poll re-reads the same bytes and the Store stays quiet. For native watching over a real directory, use config.Dir.

List a directory

config-filekv reads a whole directory, so it needs config.DirLister. Wrap claims it only where the filesystem can list: one that implements fs.ReadDirFS, such as embed.FS or fstest.MapFS, or one whose root opens as a listable directory, such as zip.Reader. Checking the root is the only I/O Wrap does. A filesystem that can do neither is wrapped without ReadDir, and configfilekv.New then returns ErrNotListable rather than contributing nothing later.

Test with fstest.MapFS

fstest.MapFS is an io/fs.FS built from a map, so a test needs no files and no embed directive:

fsys := fstest.MapFS{"defaults.yaml": {Data: []byte("log:\n  format: json\n")}}

store, err := config.NewStore(ctx, config.WithFiles(configiofs.Wrap(fsys), "defaults.yaml"))

Errors you'll see

Error Cause Fix
config.ErrReadOnlyFS A write was routed to a key that lives only in the iofs layer. Name a writable layer with config.To.
fs.ErrInvalid The name is not io/fs-valid: a leading slash, or a . or .. segment. Pass a relative, slash-separated name, and use fs.Sub to root a sub-tree.
fs.ErrNotExist The name is valid but the file is not there. Nothing, if the source is optional: the store treats it as an empty layer.

When you don't need this

For a single embedded defaults document, WithReaders takes bytes you already have and needs no module. The layering tutorial does exactly that.

Reach for config-iofs when the defaults have outgrown a string literal: several files, a directory you want to keep as files, or a zip or tar you are reading in place. The layer is read-only, which suits embedded defaults. See Ship defaults inside the binary.

What it costs

Adds nothing beyond the config graph, and an allowlist test in the module fails if that changes

It is io/fs and the standard library. That makes it as lean as the four read-only flat formats (dotenv, INI, properties and XML), which also add nothing.