Read and write config in Azure Blob¶
When a tool's configuration file lives in an Azure Blob Storage container, the
config-azure-blob sibling module wraps an
*azblob.Client as a
config.FS. You build and authenticate the client and hand it in with the container name.
import (
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"gitlab.com/phpboyscout/go/config"
configazureblob "gitlab.com/phpboyscout/go/config-azure-blob"
)
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return err
}
client, err := azblob.NewClient("https://acct.blob.core.windows.net/", cred, nil)
if err != nil {
return err
}
store, err := config.NewStore(ctx,
config.WithFiles(configazureblob.Wrap(client, "my-container"), "config.yaml"),
config.WithEnv("APP"),
)
if err != nil {
return err
}
host := store.View().GetString("db.host") // from the blob config.yaml
The blob is read, merged, written and watched exactly as a local file would be.
Connect to Blob Storage¶
Building the client yourself is the default (azidentity, a connection string, a shared key,
whatever you already use). Because the adapter takes only the SDK client, azidentity stays your
dependency and never enters the adapter's own graph. The middle rung has two shapes, and the
ambient rung lives in a subpackage:
| Use | When |
|---|---|
Wrap(client, container) |
You built the *azblob.Client, or share it with other code. Read the note on private containers first if you write. |
FSFromCredential(cred, serviceURL, container) |
You hold a principal (azcore.TokenCredential) and the account URL. |
FSFromConnectionString(conn, container) |
You hold a connection string, which carries the account URL and the secret together. |
ambient.Default(ctx, serviceURL, container) |
Nothing but the target: the ambient Azure identity chain. |
// A principal, with the account URL supplied separately.
fsys, err := configazureblob.FSFromCredential(cred, serviceURL, "config")
// A connection string, which carries the account URL AND the secret together.
fsys, err := configazureblob.FSFromConnectionString(conn, "config")
// Nothing but the target. Note the separate import.
import blobambient "gitlab.com/phpboyscout/go/config-azure-blob/ambient"
fsys, err := blobambient.Default(ctx, serviceURL, "config")
Nothing here needs closing. azblob.Client has no Close at all, because it is HTTP-backed with no
connection to release, so these rungs return a plain config.FS rather than an owned type. The why
behind the ladder is in who owns the connection.
The service URL and container are both required (ErrNoServiceURL, ErrNoContainer), an empty
connection string returns ErrNoConnectionString, and a nil credential is refused with
ErrNoCredential, including a typed nil.
Treat the connection string as a secret¶
The connection string embeds the account key. The adapter never logs it, and reports a malformed one without echoing the input.
Map blobs to keys¶
The config.FS name is the blob name, and the config keys come from the blob's contents, parsed by
the same codec a local file would use. The container must already exist. MkdirAll is a no-op,
so config-azure-blob never creates it. Provisioning the container is yours, as authenticating is.
Write back to the blob¶
Azure Blob has no atomic move, so the commit is a copy followed by a delete of the source. The copy is the synchronous copy-from-URL, so it completes within the call rather than returning a pending async operation. The target is replaced atomically. A crash between the copy and the delete can leave the temporary blob behind, which is a findable orphan and never a corrupted target. Conflict detection is the core's SHA-256 content fingerprint at load.
Copying a private blob depends on how the client was built
The synchronous copy authorises the source blob separately from the request. What the adapter can do about that depends on what it holds:
- A shared-key client (a connection string) signs the source with a short-lived read SAS, so a private same-account blob copies fine.
FSFromCredentialand the ambient rung hold your token credential, so the source carries a bearer token instead. A private container works.- A client you built yourself with
azidentityand passed toWrapkeeps its credential out of the adapter's reach, so it cannot sign the source. The copy goes unsigned, which works for a public container but is refused for a private one. If you write config to a private container, useFSFromCredential, or sign with a shared key.
Watch for changes¶
A blob has no local path, so it is watched by polling. Because each poll is a billed read,
config-azure-blob declares a 60-second default through config.PollIntervalHinter. The
adapter has no interval option of its own: override it with config.WithPollInterval on
Store.Watch. See hot-reload for the store side.
Test without Azure¶
Alongside Wrap, the module exports a narrow five-method BlobStore interface (Download,
Upload, GetProperties, Copy, Delete) and New(BlobStore), so a fake drives your tests with
no Azure. A missing blob must come back as fs.ErrNotExist:
type fakeBlobs map[string][]byte
func (f fakeBlobs) Download(_ context.Context, name string) ([]byte, error) {
if b, ok := f[name]; ok {
return b, nil
}
return nil, fs.ErrNotExist
}
func (f fakeBlobs) Upload(_ context.Context, name string, data []byte) error {
f[name] = data
return nil
}
func (f fakeBlobs) GetProperties(_ context.Context, name string) (configazureblob.BlobInfo, error) {
if b, ok := f[name]; ok {
return configazureblob.BlobInfo{Size: int64(len(b))}, nil
}
return configazureblob.BlobInfo{}, fs.ErrNotExist
}
func (f fakeBlobs) Copy(_ context.Context, src, dst string) error {
f[dst] = f[src]
return nil
}
func (f fakeBlobs) Delete(_ context.Context, name string) error {
delete(f, name)
return nil
}
fsys := configazureblob.New(fakeBlobs{"config.yaml": []byte("db:\n host: db.internal\n")})
The module's integration suite, under test/integration, runs against
Azurite under testcontainers, so it needs no Azure account. It
is skipped unless INT_TEST_INTEGRATION=1 is set, and needs a Docker daemon.
Errors you'll see¶
| Error | Cause | Fix |
|---|---|---|
config.ErrConflict |
The blob changed between load and write. | Reload and apply the change again. |
configazureblob.ErrNoServiceURL |
FSFromCredential or the ambient rung got no account URL. |
Pass the service URL. |
configazureblob.ErrNoContainer |
A rung was called with no container name. | Pass the container. |
configazureblob.ErrNoConnectionString |
FSFromConnectionString got an empty string. |
Pass the connection string. |
configazureblob.ErrNoCredential |
FSFromCredential got a nil (or typed-nil) credential. |
Check the error from the credential constructor. |
fs.ErrNotExist |
The blob is absent. | Check the name. The store tolerates it as an empty layer unless it is the first source and you set RequireFirstSource. |
| A storage error (no sentinel) | The container does not exist, or a write to a private container went through Wrap with an azidentity client. |
Create the container; for the copy, see the note above. |
Limits¶
- No directories and no POSIX mode.
MkdirAlldoes nothing, and the container is never created for you. - No local path and no symlinks, which is why the store polls rather than using a native file watcher.
When you don't need this¶
If you read one blob once at startup, fetch it and hand the bytes to
WithReaders. No adapter needed.
Reach for config-azure-blob when the container should behave like a filesystem:
several files, writes routed back, or hot-reload noticing a change made elsewhere.
What it costs¶
| Adds | the Blob Storage SDK, on top of the config graph |
The azblob SDK (deliberately without azidentity), plus config,
asserted by an allowlist test.
Related¶
- How filesystem adapters work: the object-store commit model and polled reload
- Read and write config in S3 and GCS: the sibling object stores
- Backends & capabilities: the
config.FSinterface