configiofs

package module
v0.1.12 Latest Latest
Warning

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

Go to latest
Published: Oct 15, 2026 License: MIT Imports: 3 Imported by: 0

README

config-iofs

Read configuration from any io/fs.FS through config

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


config reads through its own six-method config.FS. A large share of configuration lives on an io/fs.FS instead — compiled into the binary with embed.FS, packed in a zip/tar archive, mounted under os.DirFS, or built in an fstest.MapFS. Wrap adapts any of them, so a tool can read its own compiled-in defaults with no file on disk.

import (
	"embed"

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

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

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

The wrapped filesystem takes part in precedence, per-key merge and provenance exactly as any other file source does.

Names are io/fs names, not OS paths

io/fs uses unrooted, forward-slash-separated names with no leading slash. Pass the embedded name — "defaults/config.yaml" — exactly as you would to embed.FS.ReadFile, not "/etc/app.yaml". A name io/fs rejects (a leading slash, a .. segment, a backslash) is refused with an error wrapping fs.ErrInvalid, so a fat-fingered absolute path surfaces as the mistake it is rather than reading as a normal missing source. A valid-but-absent name still returns the ordinary fs.ErrNotExist-shaped miss.

To scope a wrapped filesystem to a sub-directory, compose fs.Sub before Wrap — there is no scoping helper here because the standard library already has one:

sub, _ := fs.Sub(defaults, "defaults")
config.WithFiles(configiofs.Wrap(sub), "config.yaml")

Read-only

io/fs has no write, rename or remove — it is read-only by design — so this adapter is too. The four write methods (WriteFile, Rename, Remove, MkdirAll) each return config.ErrReadOnlyFS, the shared sentinel a caller can errors.Is regardless of which read-only filesystem produced it. A write routed at a compiled-in embed.FS fails loudly at the point of the write, which is correct: there is nothing to write to. A consumer needing writes wraps a writable filesystem (config-afero over an in-memory afero.Fs, or config.OS), not this.

Watching

An io/fs.FS exposes no operating-system path, so a wrapped filesystem is polled when watched rather than watched natively — which is harmless for the headline embed.FS case, where a poll simply re-reads the same bytes and stays quiet. If you want native watching over a real directory, you already have the right tool: config.Dir, a real directory watched natively by construction. config-iofs is for the cases that genuinely have no OS path.

Listing a directory

Where the wrapped filesystem can list, the result also satisfies config.DirLister, so a directory-shaped backend such as config-filekv can read one file per key from it:

backend, err := configfilekv.New(configiofs.Wrap(fsys), "run/secrets")

That is every standard-library filesystem: embed.FS, os.DirFS, fstest.MapFS, fs.Sub and a zip.Reader. An io/fs.FS that is not an fs.ReadDirFS and whose root does not open as an fs.ReadDirFile cannot be listed through fs.ReadDir, so Wrap does not claim DirLister for it, and configfilekv.New says so up front instead of failing on every read.

What it costs

Just config and its own transitive set — zero dependencies of this module's own, because io/fs is the standard library. An allowlist test asserts exactly that, so an unforeseen transitive addition fails the build rather than arriving quietly.

License

See LICENSE.

Documentation

Overview

Package configiofs adapts any io/fs.FS to the read-only config.FS interface, so configuration compiled into a binary with embed.FS, packed in a zip or tar archive, mounted under os.DirFS, or built in an [fstest.MapFS] can be read by a config Store without copying it onto disk first.

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

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

The headline case is a tool shipping its own defaults compiled in: an embed.FS never changes at runtime, so reading through it is exactly reading bytes the binary already carries.

Read-only by design

io/fs has no write, rename or remove — it is read-only by design — so this adapter is too. The two read methods map onto the two stdlib helpers (fs.ReadFile and fs.Stat); the four write methods each return config.ErrReadOnlyFS, the shared sentinel a caller can errors.Is regardless of which read-only filesystem produced it. A write routed at a compiled-in embed.FS fails loudly at the point of the write, which is correct: there is nothing to write to.

Names are io/fs names, not OS paths

io/fs uses unrooted, forward-slash-separated names with no leading slash: pass "defaults/config.yaml", not "/etc/app.yaml". A name fs.ValidPath rejects — a leading slash, a ".." segment, a backslash — is refused early with an error wrapping fs.ErrInvalid, so a fat-fingered absolute path surfaces as the mistake it is rather than reading as a normal missing source. A valid-but-absent name still returns the fs.ErrNotExist-shaped error the Store treats as a normal missing overlay.

A DirLister where io/fs can list, never a RealPather or LinkReader

The wrapped filesystem satisfies config.DirLister, so a directory-shaped backend such as config-filekv can enumerate it, whenever the io/fs.FS can honour fs.ReadDir: it is an fs.ReadDirFS, or its root opens as an fs.ReadDirFile. Every standard-library filesystem qualifies. One that does neither gets a type without ReadDir rather than one that fails on every call.

io/fs exposes no operating-system path and no symlink resolution, so the wrapped filesystem deliberately satisfies neither config.RealPather nor config.LinkReader. Because it is not a config.RealPather, a watched filesystem is polled rather than watched natively — which is harmless for the headline embed.FS case, where a poll simply re-reads the same bytes and stays quiet.

If you want native watching over a real directory, you already have the right tool: config.Dir, which is a config.RealPather by construction. This package is for the cases that genuinely have no OS path.

To scope a wrapped filesystem to a sub-directory, compose fs.Sub before Wrap — configiofs.Wrap(must(fs.Sub(fsys, "conf"))) — which needs no help from this module.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Wrap

func Wrap(fsys fs.FS) config.FS

Wrap adapts any io/fs.FS to a read-only config.FS. ReadFile and Stat map onto fs.ReadFile and fs.Stat; the four write methods — WriteFile, Rename, Remove and MkdirAll — return config.ErrReadOnlyFS.

The returned value satisfies config.DirLister when the filesystem can list (see the package doc), and never config.RealPather or config.LinkReader: an io/fs.FS has no operating-system path and no symlink resolution to honour them with.

Example

ExampleWrap reads configuration compiled into the binary. An embed.FS — here the test's own fixture — satisfies io/fs.FS, so Wrap hands it to a config Store as a read-only source. Names are io/fs names: pass the embedded path, not an OS path.

store, err := config.NewStore(context.Background(),
	config.WithFiles(configiofs.Wrap(embedded), "testdata/defaults/config.yaml"))
if err != nil {
	log.Fatal(err)
}

fmt.Println(store.View().GetInt("server.port"))
Output:
8080

Types

This section is empty.

Jump to

Keyboard shortcuts

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