Who owns the connection¶
Every adapter that reads configuration from somewhere remote — Parameter Store, Key Vault, Consul, an S3 bucket — needs a client to talk to it. This page is about who builds that client, and why the answer is "you, by default, but not necessarily".
The ladder¶
An adapter that owns a connection offers up to four ways to get one. They are rungs, not alternatives: each is a thinner layer over the one below.
| Rung | What you supply | When you want it |
|---|---|---|
| 1 — narrow interface | a value satisfying the adapter's own small interface | tests and fakes; this is the seam the unit suites use |
| 2 — SDK client | a built *ssm.Client, *azsecrets.Client, *capi.Client … |
you already build clients for other reasons |
| 3 — native config | the provider's own config, credential or options | you want one credential resolution feeding several clients |
| 4 — ambient | nothing but the target | you just want it to work |
// 1 — the testing seam
configawsssm.New(fakeParamStore{}, "/app")
// 2 — you built the client
configawsssm.FromClient(ssm.NewFromConfig(cfg), "/app")
// 3 — you resolved the config; the adapter builds its own client
b, err := configawsssm.FromConfig(cfg, "/app")
// 4 — you supplied nothing but the prefix
b, err := ssmambient.Default(ctx, "/app")
Rungs 3 and 4 return an error where rungs 1 and 2 do not. That is not
inconsistency: those rungs validate something, and failing at construction is
better than failing at the first read.
Injection is the default, and stays that way¶
Rungs 1 and 2 are the ones every adapter has always had, and they remain the recommended path for anything long-lived. Three things follow from the consumer owning the client, and all three are worth keeping:
- The adapter is credential-agnostic. Every cloud does authentication differently and you have already decided how; the adapter has no business re-deciding it.
- The adapter is testable without the cloud. A fake satisfying the narrow interface drives the whole unit suite — no account, no network.
- The dependency is honest. The adapter pulls only the service package it needs. The credential-resolution graph, which is much larger, is yours only if you ask for it.
That last point is the one the ambient rung has to work around.
Why the ambient rung sometimes lives in a subpackage¶
Resolving an ambient credential chain is not free. Measured against the library graph each adapter actually hands a consumer:
| Family | Rung 3 | Rung 4 |
|---|---|---|
| AWS | free | +7 to +10 modules |
| Azure | free | +7 modules |
| GCP | free | free |
| Vault, Consul | free | free |
Where the ambient rung is free, it sits in the adapter's main package and you
call Default(…). Where it is not, it sits in a subpackage:
import ssmambient "gitlab.com/phpboyscout/go/config-aws-ssm/ambient"
b, err := ssmambient.Default(ctx, "/app")
One extra import, and in exchange the adapter's own dependency footprint is exactly what it always was. Each of those subpackages carries a test asserting its parent's graph has not grown — so if the split ever leaks, the build says so.
AWS costs 7 on config-aws-s3 and 10 on the other two, because S3's larger
service graph already carries three of the credential modules. Each adapter
measures its own rather than quoting a sibling's.
Sharing a connection is deliberate, never automatic¶
Two adapters that each take their ambient default resolve the provider chain twice. That is intended, not an oversight.
A hidden process-wide cache would be worse than the duplication it saves: it would silently share credentials between components that may deliberately differ — a different profile, an assumed role, a distinct tenant — and it would make one component's transient failure everybody's, invisibly.
If you want one resolution feeding several adapters, say so:
src := awsclient.Ambient(awsclient.WithRegion("eu-west-2"))
ssmBackend, err := ssmambient.FromSource(ctx, src, "/app")
s3fs, err := s3ambient.FSFromSource(ctx, src, "my-bucket")
The same source can feed go/signing
and go/encryption too — it is an
estate module, not a config one.
The provider client modules¶
There is one per provider, deliberately. A single combined module would drag every cloud SDK into one dependency graph and destroy the segregation this family is built on, so each is imported only by consumers who want that provider.
| Module | Yields | Notes |
|---|---|---|
go/awsclient |
aws.Config |
refuses to guess a region |
go/azureclient |
azcore.TokenCredential |
guards the typed-nil interface case |
go/gcpclient |
[]option.ClientOption |
options, not a client — three GCP adapters need three client types |
go/vaultclient |
*vaultapi.Client |
for Vault the client is the prerequisite |
Each offers the same shape: inject what you have, or take the ambient default —
plus a non-memoising PerCall rung, because how long a component holds a
credential is a security posture belonging to that component rather than to the
module. All of them share one state machine,
go/clientlifecycle, which
has no dependencies of its own and never caches a failure.
Two of the five have no consumer in this family: the GCP adapters take
option.ClientOption directly and config-vault uses vaultapi.DefaultConfig().
They exist for the estate — go/vaultclient in particular for the signing and
encryption adapters that follow.
An ambient rung supplies credentials, never the target¶
This is the rule that holds across every adapter, and it is worth stating because
it explains a lot of error returns.
The ambient rung answers who am I. It never answers what am I talking to. Every adapter refuses when the target is missing, rather than guessing:
| Refused | Adapters |
|---|---|
| region | aws-ssm, aws-secrets, aws-s3 |
| project | gcp-secret, gcp-parameter |
| location | gcp-parameter |
| bucket, container | gcp-gcs, aws-s3, azure-blob |
| vault URL, endpoint, service URL | azure-keyvault, azure-appconfig, azure-blob |
| address | vault |
There is one deliberate exception in the other direction. config-vault's
Default inherits Vault's own documented 127.0.0.1:8200, because adopting a
provider's documented default is not the same act as inventing one. AWS
documents no default region, so config-aws-ssm refuses instead.
Some rungs hand you something to close¶
Where a rung builds the client, it owns it — and if that client holds a
connection, the rung returns a concrete type carrying Close rather than the
bare interface:
b, err := configgcpsecret.Default(ctx, "my-project", "")
if err != nil { return err }
defer b.Close() // a service closes it; a CLI simply does not
store, err := config.NewStore(ctx, config.WithBackend(b))
The obligation follows who built the client and whether the SDK gives you anything to release:
| Adapter | Client | Returns |
|---|---|---|
config-gcp-secret, config-gcp-parameter |
gRPC — "must be Closed" | *OwnedBackend |
config-gcp-gcs |
HTTP — "need not be called at program exit" | *OwnedFS |
config-sftp |
a subsystem channel | *OwnedFS — closes the subsystem, never your SSH connection |
config-azure-blob |
HTTP — no Close at all |
plain config.FS |
| everything else | nothing to release | plain config.Backend |
Rungs 1 and 2 never return an owned type: there, you built the client and still own it.
What a self-connecting backend guarantees¶
A backend that resolves its own connection still behaves like any other layer, and the backend conformance suite checks it:
- It can say what it is before it connects.
ID()andCapabilities()answer without a network call, so building aStorenever becomes a network operation. - A connection failure is an ordinary error from
Load— not a panic, and not "source not found". Those are different answers: a missing source may be perfectly fine, an unreachable one is not, and conflating them leaves your configuration silently short of a layer. - A failure is never remembered. A credential chain that was not ready when
your tool started is picked up by the next reload rather than needing a
restart. This is why none of these adapters use
sync.OnceValues— it caches the first error for the life of the process.
A credential, or a way to get one¶
That last guarantee is about a failure the adapter can retry. There is one it cannot, and which rung 4 makes easier to meet — because rung 4 hands the adapter a credential you never see.
The property that decides it:
Does the object the adapter holds know how to obtain a fresh credential, or is it already a credential?
Where it is a means, the SDK renews underneath you and a process can run for weeks:
| Provider | What is held | Renews? |
|---|---|---|
| AWS | a provider chain behind aws.NewCredentialsCache |
yes, on expiry |
| Azure | a TokenCredential the pipeline re-calls |
yes, before expiry |
| GCP | a cached token provider | yes, once stale |
| etcd | a username and password | yes — it re-authenticates |
| keychain | nothing at all; every call re-resolves | not applicable |
Where it is an already-minted token, nothing renews it:
| Provider | What is held | Renews? |
|---|---|---|
| Vault | the token VAULT_TOKEN carried |
no |
| Consul | the resolved ACL token | no |
Both HashiCorp SDKs model their client as configured with a token rather than
configured with a way to get a token, and neither re-reads its environment.
Consul additionally reads CONSUL_HTTP_TOKEN_FILE once, at construction, though
a token file is precisely the mechanism you would rotate.
The consequence only bites a long-lived process reloading configuration: once
the token's lifetime passes, every Load fails and nothing recovers, because a
backend cannot tell "my credential expired" from "I am not allowed" — and those
want opposite responses. A command that runs for a second and exits never notices.
The ladder did not cause this. FromClient always behaved this way. What
rung 4 changes is visibility: when you built the client, the token was in your
hands; when the adapter builds it, nobody confronts it. So the two adapters where
it is true say so on Default itself.
If your process outlives its token, build the client, renew or rebuild it, and
hand it to FromClient — rung 2 doing exactly the job rung 2 exists for.
For completeness: a connection string is a static secret, and Azure's may carry a SAS with an expiry. That rung is injected rather than ambient, so the credential is already in your hands — the same hazard, but not a hidden one.
Three adapters that stop short, and why¶
config-etcd has no ambient rung, permanently. clientv3 has neither an
ambient credential chain nor endpoint discovery — unlike every other provider
here, both halves would have to be invented. A Default() could only be
FromConfig with made-up environment parsing bolted on, and everything of
substance there is already FromConfig. Use FromConfig with the endpoints you
know.
config-sftp stops at rung 3. An ambient SSH default would have to choose a
host-key verification policy on your behalf, and every option is wrong for
somebody: trust-on-first-use silently accepts a man in the middle, requiring
known_hosts fails exactly the machines most likely to want zero configuration,
and skipping verification is not something a configuration library should ever
ship. Dial the *ssh.Client yourself and hand it to FromSSH.
config-keychain already has rung 4 under a different name.
configkeychain.Registered() resolves through the credentials registry, so what
you get depends on whether you blank-imported the keychain backend — and if you
did not, it reports unavailable rather than pretending. That name says more than
Default() would, so it keeps it.
The format and filesystem adapters have no connection at all¶
config-json, config-toml, config-hcl, config-ini, config-xml,
config-properties, config-dotenv, config-afero, config-billy,
config-iofs, config-filekv and config-schema read through a config.FS or
operate on bytes you already have. There is no client to build, so the ladder
does not apply to them.
Where this is specified¶
config spec 0012 decides the config side. It implements org spec 0003, which defines the provider client modules, over org spec 0002, which settles the connection lifecycle for the whole toolkit.