Filesystem adapters¶
Problem¶
The module reads and writes configuration through its own small
config.FS interface. Two implementations exist: the built-in config.OS()
(the real operating-system filesystem) and config.Dir (one rooted at a directory), plus the
sibling config-afero, which bridges an
afero.Fs a consumer already holds. But a large and growing share of configuration lives on a
filesystem that is none of those: compiled into the binary (embed.FS), in a go-billy
filesystem a tool already uses, on a remote host over SSH, or in a cloud object store —
AWS S3, GCP Cloud Storage, Azure Blob Storage. A tool whose configuration lives in one of those
cannot point this module at it today.
The seam to reach them already exists and is proven: WithFiles, NewFileBackend and
NewWatcher all take a config.FS, and config-afero shows a sibling module can supply one. So
the question is not can the module read from these filesystems — it demonstrably can — but which
ones we ship as first-class sibling adapters, and under what shared conventions.
This spec is the umbrella. It establishes the family of config-<fs> filesystem adapters and
the decisions that cut across all of them. It deliberately does not specify any single adapter:
an io/fs wrapper is a dozen lines with no dependency, while an S3 wrapper carries an SDK, an
object-store consistency model and a rename that is not a rename. So, as with the backend family,
each adapter gets its own approved spec before it is built (D2) — proportionate to how much it
actually diverges.
The contract every adapter meets¶
config.FS is six methods, and the load-bearing one is Rename:
type FS interface {
ReadFile(name string) ([]byte, error) // fs.ErrNotExist when absent
WriteFile(name string, data []byte, perm fs.FileMode) error
Stat(name string) (fs.FileInfo, error)
Rename(oldpath, newpath string) error // the atomic commit
Remove(name string) error
MkdirAll(path string, perm fs.FileMode) error
}
A write is committed by staging content beside the target and renaming over it, so a reader
never observes a half-written file. Two optional interfaces refine behaviour: RealPather
(does a name map to a real OS path?) — which is what the fsnotify hot-reload watcher needs — and
LinkReader (symlink resolution). An adapter implements the six methods and optionally the two.
Decisions¶
D1 — One sibling config-<fs> module per filesystem, cloud-qualified where it names a cloud¶
Every filesystem ships as its own module, depended on only by consumers who use it — the same
decision, for the same reason, as the format and backend adapters. It is sharpest for the cloud
stores, whose SDKs are large: a consumer reading config from S3 must not acquire the GCP or Azure
SDK. Naming follows the backend family (dynamic backend adapters D1): a cloud service carries its
cloud — config-aws-s3, config-gcp-gcs, config-azure-blob — and a vendor-neutral filesystem
stays bare — config-iofs, config-billy, config-sftp. (A read-only HTTP-URL adapter and a
git-ref adapter were considered and dropped as too niche for the cost; see Resolved.)
Two things are not adapters and must not become modules: the operating-system filesystem is
already config.OS() in the core, and an embed.FS is an io/fs.FS, so it is served by
config-iofs — there is no config-os and no config-embed to build.
D2 — Each adapter has its own approved spec, proportionate to its divergence¶
No config-<fs> adapter is implemented until it has its own status: approved spec, kept
centrally here alongside this umbrella. But filesystem adapters vary enormously in how much they
diverge, and the spec should match: config-iofs is a read-only wrapper over a stdlib interface
and its spec is a page; config-aws-s3 carries an SDK, an eventual-consistency story, a
rename-is-copy-then-delete model and an emulator test rig, and its spec earns every section. Each
per-adapter spec settles: the underlying type it wraps and how each config.FS method maps onto
it; its capability (read-only or read+write; real-path/watchable or not); how writes commit and
what atomicity actually holds; its dependency footprint with the allowlist; and its testing
surface (fake/in-memory and, where one exists, an emulator).
D3 — The adapter wraps an injected client or filesystem; it owns no credentials¶
An adapter's constructor takes the already-configured underlying thing — an io/fs.FS, a
billy.Filesystem, an *s3.Client, an sftp.Client — behind the module's own Wrap, never
re-deciding authentication, region, endpoint or host. This is exactly config-afero's
Wrap(afero.Fs) pattern and the backend family's D3: the consumer configures the client (which is
where the SDK dependency honestly lives), and the adapter only translates the six config.FS
calls. A fake or in-memory underlying filesystem then drives the unit suite with no cloud.
D4 — Capability is what the underlying filesystem genuinely supports¶
A read-only filesystem yields a read-only adapter: ReadFile and Stat work, and the write
methods (WriteFile, Rename, Remove, MkdirAll) return config.ErrReadOnlyFS — a shared
sentinel added to the core for this family, so a consumer can errors.Is it regardless of which
read-only filesystem produced it, rather than each adapter returning a bare fs.ErrPermission a
real permission error is indistinguishable from. io/fs is read-only by design (it has no write,
rename or remove), so config-iofs is read-only; config-billy, the cloud stores and
config-sftp are read+write. A read-only filesystem is a first-class citizen: a write routed at it
fails loudly at the point of the write, exactly as it would for a read-only file on disk.
D5 — Hot-reload is native where there is a real path, and polled where there is not¶
The watcher uses fsnotify for a filesystem that reports real OS paths (RealPather), and — this
already ships in the core — falls back to a pollWatcher for one that does not. So a
filesystem without real paths (an embed.FS, an object store, SFTP) is not un-watchable: when the
consumer calls Watch, the Store polls it, re-reading and staying quiet if nothing resolved
differently, with the existing WithPollInterval setting the cadence (DefaultPollInterval, 2s).
No new mechanism is needed — the poll watcher and WithPollInterval are already there, so the
remote adapters get opt-in polled reload for free.
The one thing the adapters owe here is a sensible interval, not a mechanism: for a cloud store a
poll is a billed API call and the 2-second default is far too eager, so each cloud adapter's spec
recommends a much longer default and documents the cost. (An embed.FS never changes, so watching
it is pointless but harmless.) The shared convention is therefore: rely on the core's poll watcher,
tuned per adapter — not a new per-adapter watch API.
Revised — see R1 below. "Recommends a default in its spec" left the interval as prose an adapter could only honour by asking the consumer to pass
WithPollInterval. R1 adds a small optional core interface,config.PollIntervalHinter, so an adapter declares its cadence and the watcher adopts it — the mechanism D5 said was unnecessary turned out to be one narrow interface, matchingRealPather/LinkReader.
D6 — Object stores are not POSIX: the rename is a copy, and that is fine¶
S3, GCS and Azure Blob are object stores, and the config.FS contract meets them honestly rather
than pretending they are disks:
Renameis copy-then-delete. There is no atomic rename. ButWriteFilemaps toPutObjectand rename's copy maps toCopyObject, both of which are atomic per object — so the target is still replaced atomically and a reader never sees a half-written object. The only imperfection is that a crash between the copy and the delete leaves the staged object behind: garbage to be cleaned up, never corruption of the target. (Revised — see R2 below: a store with a native atomic move uses it instead of copy-then-delete.)MkdirAllis a no-op — object stores have no directories, only key prefixes.Statsynthesizes afs.FileInfofrom object metadata (size, last-modified); mode bits are nominal.- Conflict detection still holds. The write path fingerprints content by SHA-256 at load and refuses a write if the source changed underneath it — this is content-based, not rename-based, so it works unchanged over an object store (these stores are strongly read-after-write consistent).
The consequence — extra API calls per commit and a possible orphaned staging object on crash — is stated in each cloud adapter's spec, not hidden.
D7 — The SDK is the honest cost, stated per adapter¶
config-iofs adds zero dependencies (it is stdlib). config-billy adds a small one. The
cloud adapters each carry their provider's storage SDK, which is the largest thing in their graph,
asserted by an allowlist depfootprint test and stated in the README — and, because each is its
own module, a consumer reading from S3 never compiles the GCS or Azure SDK.
D8 — Testing is a fake/in-memory filesystem plus an emulator where one exists¶
Every adapter's unit suite runs against an in-memory or fake underlying filesystem, needing no
network — fstest.MapFS for io/fs, billy's memfs, a fake object store for the cloud ones.
The cloud stores are then additionally proven against a real emulator in an env-gated
integration suite: LocalStack (S3), fake-gcs-server (GCS) and Azurite (Azure Blob) all
exist and run under testcontainers, so — unlike some backend adapters — all three cloud filesystem
adapters can have real integration tests in a Docker-in-Docker job. config-sftp tests against an
in-process SSH/SFTP server. Each per-adapter spec names its fake and its emulator.
Rejected alternatives¶
A config-os module. The OS filesystem is already config.OS() in the core; wrapping it in a
sibling module would duplicate what ships built in.
A config-embed or config-zip module. embed.FS, archive/zip and archive/tar all
expose io/fs.FS, so config-iofs serves them directly. One read-only stdlib adapter covers the
whole io/fs ecosystem.
A FUSE adapter. A FUSE-mounted filesystem is already an operating-system path, reached through
config.OS(); there is nothing to wrap. Go FUSE libraries implement a mount (expose a filesystem
to the OS), which is the opposite direction. Exposing the merged configuration as a FUSE mount is
a genuinely different feature, not a config.FS adapter, and is out of scope.
Treating the cloud stores as Backends instead. A Backend over object keys as config keys
(the parameter-store shape) is a different, legitimate thing — but it is not what "filesystem
adapter" means. These adapters read a config file that happens to live in a bucket, through the
same codec and write machinery a local file uses. The object-as-key model, if ever wanted, is a
separate spec.
One config-fs module with every filesystem. Rejected on the same dependency ground as one
config-formats or one config-remote module: it would pull every cloud's storage SDK into every
consumer's graph. See D1.
Public API¶
Each adapter exports a Wrap that adapts its underlying filesystem to config.FS, mirroring
config-afero:
configiofs.Wrap(embeddedFS) // fs.FS → read-only config.FS
configbilly.Wrap(billyFS) // billy.Filesystem → config.FS
configs3.Wrap(s3Client, "my-bucket") // *s3.Client + bucket → config.FS
configsftp.Wrap(sftpClient) // *sftp.Client → config.FS
The result is passed to WithFiles/NewFileBackend exactly as config.OS() is. The core needs
two small additions: the exported sentinel config.ErrReadOnlyFS (D4) that the read-only
adapters return from their write methods, and the optional interface config.PollIntervalHinter
(R1) that a polled adapter implements to declare its own reload cadence. Everything else the
adapters need —
config.FS, RealPather, LinkReader, and the poll watcher that reloads a non-real-path
filesystem (D5) — already exists and is sufficient, proven by config-afero implementing the
interfaces against the public API.
Testing strategy¶
Per adapter: an in-memory/fake-driven unit suite (D3, D8); for the cloud stores, an env-gated
emulator integration suite under testcontainers (D8); an allowlist depfootprint test (D7). The
umbrella is verified by the adapters passing the module's existing filesystem behaviour — a wrapped
filesystem must read, write, stage-and-rename, and preserve permissions exactly as config.OS()
does, which the module's own suite already asserts against any config.FS.
Migration & compatibility¶
Additive for consumers: a consumer adds a module and passes its Wrap to WithFiles, exactly as
for config-afero. No core change; nothing breaks.
Resolved (2026-07-22)¶
- The adapter set. Six adapters:
config-iofs,config-billy,config-aws-s3,config-gcp-gcs,config-azure-blob,config-sftp. A read-onlyconfig-http(URL fetch) and aconfig-git(file at a ref) were considered and dropped — HTTP overlaps with simply fetching the file yourself, and git-at-a-ref is genuinely niche; neither earns its surface yet. Either can be added later by a dated revision if a consumer needs it. - Polling reload. No new mechanism — the core already polls a filesystem without real
paths (D5):
Watchbuilds apollWatcherfor it, tuned by the existingWithPollInterval. So the remote adapters get opt-in polled reload for free; the shared convention is to rely on that and have each cloud adapter recommend a sensible (long) default interval, because a poll is a billed API call. This was verified againstwatch.gobefore deciding. - Read-only write errors. A shared
config.ErrReadOnlyFSsentinel is added to the core (D4), returned by every read-only adapter's write methods, so a consumer canerrors.Isit rather than guess from a barefs.ErrPermission. It is the one small core addition this family needs.
Implementation phases¶
Phase 0 — this umbrella spec.
Phase 1 — the core precursor. Add and release the exported config.ErrReadOnlyFS sentinel (D4)
that the read-only adapters depend on. Small and additive; everything else the family needs already
ships (the poll watcher, RealPather, LinkReader).
Phase 2 — the POSIX-shaped adapters. config-iofs (read-only, zero-dependency, over any
io/fs.FS including embed.FS) and config-billy (read+write), each its own approved spec then
module. They validate the family against the simplest cases and add no cloud surface.
Phase 3 — the cloud object stores. config-aws-s3, config-gcp-gcs, config-azure-blob — the
symmetric trio, each D2-gated, each with an emulator integration suite (LocalStack, fake-gcs-server,
Azurite; D8). The object-store atomicity model (D6) is proven here.
Phase 4 — remote and revisit. config-sftp. Anything the earlier adapters showed this umbrella
got wrong is corrected here by dated revision, never silently.
Revisions¶
R1 (2026-07-22) — a filesystem declares its poll cadence via an optional interface¶
Revises D5. D5 said the remote adapters get polled reload "for free" and owe only "a sensible
interval, not a mechanism", to be recommended in each adapter's spec. Finalising the per-adapter
specs exposed the gap: a recommendation in prose is not a cadence the adapter can actually apply.
The store hands every backend's Watch the resolved interval — DefaultPollInterval (2s) unless
the consumer set WithPollInterval — so a cloud adapter that "wants" 5 minutes has no way to say
so; it can only hope the consumer passes WithPollInterval, and a consumer who forgets silently
gets 2-second billed polling. That is the eager default D5 set out to avoid, reintroduced.
The fix is one narrow, optional interface in the core — the same shape as RealPather and
LinkReader, which is precisely the extensibility pattern the config.FS contract was designed
around (filesystem-abstraction spec D1):
// PollIntervalHinter optionally reports how often a polled filesystem should be
// checked for changes.
type PollIntervalHinter interface {
PollInterval() time.Duration
}
NewWatcher consults the hint only where the caller left the default: an explicit
WithPollInterval still wins, a filesystem that does not implement the interface keeps the
responsive 2-second default, and a remote adapter that does implement it gets its own sensible
cadence without the consumer having to know the number. This touches nothing in the store or the
remote-backend watch path, so it cannot alter any shipped backend's polling, and the existing
watch-conformance timing is unaffected. config.PollIntervalHinter joins config.ErrReadOnlyFS
(D4) as the second and final small core addition this family needs; each read+write cloud and
config-sftp adapter implements it to declare a minutes-scale default, and each per-adapter spec
states the value it picks.
R2 (2026-07-22) — rename uses each store's native move where it has one¶
Revises D6. D6 modelled every object store's Rename as copy-then-delete on the grounds that
"there is no atomic rename". That is true of S3 and Azure Blob, but not universally: GCS has a
native Move (a server-side rename within a bucket) and SFTP has PosixRename (an atomic
rename extension). Copy-then-delete leaves a staged object behind on a crash between the two calls
(D6's stated imperfection); a native move has no such window and is a single round-trip. So the
decision is refined: an adapter uses its store's native atomic move where the store has one, and
falls back to copy-then-delete only where it does not.
config-gcp-gcs—Object.Move(server-side, atomic).config-sftp—PosixRename(atomic rename extension), falling back to plainRename.config-aws-s3,config-azure-blob— copy-then-delete, exactly as D6 describes, because these stores genuinely have no atomic rename. The orphaned-staging-object caveat applies to these two, and each states it.
The config.FS contract is unchanged — Rename is still the atomic commit — and so is the
conflict-detection story (SHA-256 at load, D6). Only the implementation of the commit differs per
store, which is exactly what a per-adapter spec is for (D2). Each adapter's spec names the primitive
it uses.