Documentation
¶
Overview ¶
Package configazureblob adapts an Azure Blob Storage container to the config.FS interface, so a tool whose configuration lives in a blob can hand the container to a config Store and read — and write — it through the same codec and stage-and-rename machinery a local file uses.
cred, _ := azidentity.NewDefaultAzureCredential(nil) // consumer's dep
client, _ := azblob.NewClient("https://acct.blob.core.windows.net/", cred, nil)
store, err := config.NewStore(ctx,
config.WithFiles(configazureblob.Wrap(client, "config"), "app/config.yaml"))
reads the blob named app/config.yaml from container config. The blob's name is the file path: it is passed to the object operations unchanged, and the container is bound once, at Wrap.
An object store is not a disk ¶
Azure Blob Storage is reached over HTTPS with an Azure credential. It has no directories (only key prefixes), no atomic rename (only copy-then-delete), no stat (only GetProperties returning object metadata), and no native change notification. The adapter meets it honestly rather than pretending it is a disk:
- Rename is a server-side copy followed by a delete. Each object write is atomic, so the target is still replaced atomically and a reader never observes a half-written blob — but a crash between the copy and the delete leaves the staging blob behind. That is garbage to clean up, never corruption of the target.
- MkdirAll is a no-op — there are no directories to make. The container must already exist; provisioning it is the consumer's job.
- Remove of an absent blob succeeds. This diverges from os.Remove, matching the idempotent intent of discarding staged content.
- A blob has no operating-system path, so the wrapped container is not a config.RealPather and is watched by polling. Each poll is a billed blob read, so the adapter implements config.PollIntervalHinter and advertises a 60-second default (raise it with config.WithPollInterval).
The BlobStore seam ¶
The whole unit suite runs against a fake: Wrap adapts a real *azblob.Client to the narrow BlobStore interface, and New takes any BlobStore, so a consumer can inject a fake or a custom implementation. Building the client — and therefore choosing the credential and endpoint — is the consumer's job, so this package never touches azidentity.
Index ¶
- Variables
- func FSFromConnectionString(conn, container string) (config.FS, error)
- func FSFromCredential(cred azcore.TokenCredential, serviceURL, container string) (config.FS, error)
- func New(store BlobStore) config.FS
- func Wrap(client *azblob.Client, container string) config.FS
- type BlobInfo
- type BlobStore
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNoCredential reports a nil credential supplied to [FSFromCredential]. // // It covers the TYPED nil too: a nil *azidentity.DefaultAzureCredential // carried in an [azcore.TokenCredential] interface compares unequal to nil, // which is what a dropped constructor error leaves you holding. ErrNoCredential = errors.NewSentinel("configazureblob.no_credential", "no Azure credential supplied; build one with azidentity, or use the ambient rung") // ErrNoServiceURL reports a rung called with no storage-account URL. // // Unlike an AWS region there is nothing ambient to fall back on: an Azure // credential names a principal and carries no endpoint. ErrNoServiceURL = errors.NewSentinel("configazureblob.no_service_url", "no service URL supplied; pass https://<account>.blob.core.windows.net/") // ErrNoContainer reports a rung called with no container name. ErrNoContainer = errors.NewSentinel("configazureblob.no_container", "no container supplied; pass the container the configuration blob lives in") // ErrNoConnectionString reports an empty connection string supplied to // [FSFromConnectionString]. ErrNoConnectionString = errors.NewSentinel("configazureblob.no_connection_string", "no connection string supplied; read it from the storage account's access keys") )
Functions ¶
func FSFromConnectionString ¶ added in v0.3.0
FSFromConnectionString builds the client from one of the storage account's access keys.
It sits alongside FSFromCredential rather than beneath it because Blob Storage genuinely has both shapes: a connection string carries the account URL AND the secret together, where a credential names a principal and needs the URL supplied separately.
The connection string is a SECRET — it embeds the account key. It is never logged here, and a malformed one is reported without echoing the input, because that is how a secret reaches a log.
func FSFromCredential ¶ added in v0.3.0
func FSFromCredential( cred azcore.TokenCredential, serviceURL, container string, ) (config.FS, error)
FSFromCredential builds the blob client from a credential the caller holds and returns a config.FS over the container.
azblob.NewClient does no I/O — it errors only on a malformed service URL — so this rung constructs eagerly and reaching the container stays deferred to the first read.
Nothing here needs closing ¶
Unlike its Cloud Storage and Secret Manager counterparts, azblob.Client has NO Close: it is HTTP-backed with no connection to release, so this rung returns a plain config.FS rather than a type carrying Close. The obligation follows the SDK — where there is nothing to release, inventing a Close would be ceremony (spec 0012 L-7, R2).
Where the ambient rung is ¶
Deliberately NOT here. Resolving the ambient Azure identity chain costs this module seven further dependencies, so it lives in the ambient subpackage over go/azureclient:
import blobambient "gitlab.com/phpboyscout/go/config-azure-blob/ambient" fsys, err := blobambient.Default(ctx, serviceURL, "config")
func New ¶
New adapts a BlobStore to a read+write config.FS.
It is the injection seam: pass a fake in tests, or a Wrap-adapted client in production. The returned value satisfies config.FS and, deliberately, neither config.RealPather nor config.LinkReader — an object store has no operating-system path and no symlinks — so the core polls it when watched. It does implement config.PollIntervalHinter, advertising a 60-second poll default.
func Wrap ¶
Wrap adapts an Azure Blob container to a read+write config.FS. The client is already configured — its endpoint and credential are the consumer's (this package never touches azidentity) — and container is bound for the lifetime of the returned FS.
It is the convenience path over New: it adapts the account-scoped *azblob.Client to the narrow BlobStore interface, deriving a per-blob client for the two operations the account client cannot express (GetProperties and the copy). A consumer never sees BlobStore unless testing.
Example ¶
ExampleWrap reads configuration from a blob in an Azure Blob Storage container. You build and configure the *azblob.Client — its endpoint and credential stay yours, so azidentity is your dependency, not this package's — and hand it in with the container name. The blob's name is the file path, so the blob app/config.yaml is read as that file and its content parsed by the codec.
package main
import (
"context"
"fmt"
"log"
"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"
)
func main() {
ctx := context.Background()
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
log.Fatal(err)
}
client, err := azblob.NewClient("https://acct.blob.core.windows.net/", cred, nil)
if err != nil {
log.Fatal(err)
}
store, err := config.NewStore(ctx,
config.WithFiles(configazureblob.Wrap(client, "config"), "app/config.yaml"))
if err != nil {
log.Fatal(err)
}
fmt.Println(store.View().GetInt("server.port"))
}
Output:
Types ¶
type BlobInfo ¶
type BlobInfo struct {
// Size is the blob's content length in bytes.
Size int64
// LastModified is the blob's last-modified time, reported as ModTime.
LastModified time.Time
// ETag is the opaque entity tag for the blob's current state, available to a
// consumer who reads GetProperties directly. Conflict detection does not
// depend on it — the core fingerprints content by SHA-256.
ETag string
}
BlobInfo is the object metadata [adapter.Stat] synthesises an fs.FileInfo from.
type BlobStore ¶
type BlobStore interface {
// Download returns the blob's content, or an error satisfying
// errors.Is(err, fs.ErrNotExist) when the blob is absent.
Download(ctx context.Context, name string) ([]byte, error)
// Upload replaces the blob's content in one atomic put.
Upload(ctx context.Context, name string, data []byte) error
// GetProperties returns the blob's metadata, or an error satisfying
// errors.Is(err, fs.ErrNotExist) when the blob is absent.
GetProperties(ctx context.Context, name string) (BlobInfo, error)
// Copy replaces dst with a server-side copy of src. The copy is
// synchronous: it completes within the call for the small blobs a config
// file is.
Copy(ctx context.Context, src, dst string) error
// Delete removes the blob. An absent blob reports an error satisfying
// errors.Is(err, fs.ErrNotExist); the adapter treats that as already-removed.
Delete(ctx context.Context, name string) error
}
BlobStore is the slice of Azure Blob Storage this adapter uses, scoped to one container. A fake satisfying it drives the whole unit suite; the real *azblob.Client is adapted to it by Wrap.
Download and GetProperties report an absent blob as an error satisfying errors.Is(err, fs.ErrNotExist); that mapping is load-bearing, because the Store treats a missing optional source as normal and anything else as fatal. A missing container is a misconfiguration, not a normally-absent overlay, so it surfaces as its raw error rather than as fs.ErrNotExist.