configazureappconfig

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 15 Imported by: 0

README

config-azure-appconfig

Read and write Azure App Configuration as a config layer 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 files. A remote system like Azure App Configuration is a sibling backend module like this one, so a consumer who configures from App Configuration takes it and one who does not pays nothing for it.

You build and configure the App Configuration client — that is where every endpoint, credential and connection-string decision lives; the credential is yours, via azidentity or a connection string, and this module never imports it. You hand the client in with a prefix that scopes and is stripped from the keys:

import (
	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
	"github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig"
	"gitlab.com/phpboyscout/go/config"
	configazureappconfig "gitlab.com/phpboyscout/go/config-azure-appconfig"
)

cred, _ := azidentity.NewDefaultAzureCredential(nil)
client, _ := azappconfig.NewClient("https://example.azconfig.io", cred, nil)

store, err := config.NewStore(ctx,
	config.WithFiles(fsys, "/etc/app.yaml"),                                  // YAML defaults
	config.WithBackend(configazureappconfig.FromClient(client, "app/")),      // App Configuration outranks them
)

An App Configuration layer takes part in precedence, per-key merge, provenance and hot-reload exactly as a file does. Settings under the prefix, split on /, become the nested tree:

app/server/host   = "localhost"        store.View().GetString("server.host") // "localhost"
app/server/port   = "8080"        →    store.View().GetInt("server.port")    // 8080

Labels

A setting's identity is (key, label), not key alone. WithLabel scopes a backend to one label — it both bounds the read and pins new writes to that label; omitted, the backend uses the store's no-label default:

configazureappconfig.FromClient(client, "app/", configazureappconfig.WithLabel("prod"))

Composing several labels is a consumer concern, not something the adapter reinvents internally: stack a no-label base and a prod overlay as two backends, two layers, and let the Store's precedence and per-key merge compose them — which is exactly what they are for.

Values: strings, or decoded documents

App Configuration stores a string value per setting, so by default every value is a scalar string and the View's typed accessors coerce it (GetInt parses "8080") — the natural model for the flat-key style.

For the other common style, where one key holds a whole JSON or YAML document, pass a value codec — any config.Codec, which the sibling format adapters implement:

import configjson "gitlab.com/phpboyscout/go/config-json"

configazureappconfig.FromClient(client, "app/", configazureappconfig.WithValueCodec(configjson.Codec{}))

A value that decodes to an object becomes a subtree; a bare scalar stays a string. You inject the one format your store actually holds, so this module takes no codec dependency of its own. A setting's declared content type is never used to pick a codec — you name the format explicitly.

Two special kinds are handled at load: feature-flag settings are filtered out (out of scope for this family), and a Key Vault reference is passed through as its opaque string and not resolved — resolving it into a secret is a separate adapter's job, and the reference is a pointer, not the secret.

Writing

The backend is a write target: store.Apply(ctx, config.Set(...), config.Remove(...)) writes back to App Configuration, each setting guarded by the ETag it had when it was read, so a write that would clobber a concurrent change is refused with config.ErrConflict rather than overwriting it.

One caveat to understand up front: AtomicMultiKey is false. App Configuration has no multi-key transaction, so a batch touching several settings is applied as a sequence of per-key compare-and-swaps, not one indivisible commit. If one setting in the batch has moved since load, the ones already written are rolled back best-effort and the batch fails with ErrConflict — but the store offers no true atomicity to lean on. A single-key write, the common case, is always safe.

Watching

App Configuration has no change feed, so hot-reload polls (NativeWatch is false; the latency is the poll interval, not push). The recommended mechanism is a sentinel key — a setting your deploy bumps after changing the configuration — which the watch checks with one conditional GET per interval:

configazureappconfig.FromClient(client, "app/", configazureappconfig.WithSentinelKey("app/sentinel"))

Without a sentinel, the watch falls back to re-listing the prefix each interval — correct, but heavier. The cadence is the Store's WithPollInterval, defaulting to 30 s.

Injecting the client

FromClient wraps the real SDK client. For testing, or to supply your own client wrapper, New takes the narrow Store interface directly and Wrap adapts an *azappconfig.Client to it — so the whole unit suite runs against a fake, with no Azure account.

What it costs

The config graph plus the Azure App Configuration SDK (github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig and its azcore runtime) — a strikingly small graph of five modules, asserted by an allowlist test so an unforeseen transitive addition fails the build rather than arriving quietly. In particular azidentity is not here: building the credential is your job, so the adapter never takes on the credential library.

Testing against a real store

There is no local emulator for App Configuration, so the integration suite under ./test/integration hits a real store: it is gated on INT_TEST_INTEGRATION=1 and reads a connection string from APPCONFIG_CONNECTION_STRING, skipping cleanly without them. The merge gate stays cloud-free — an in-memory fake plus the shared backendconformance suite carry it.

License

See LICENSE.

Documentation

Overview

Package configazureappconfig contributes configuration from an Azure App Configuration store as a first-class config layer: precedence, per-key merge, provenance, safe hot-reload and compare-and-swap writes, exactly like a file layer.

A key-filter prefix scopes the backend to one namespace of the store; the keys beneath it, split on "/", become the layer's nested tree. App Configuration stores a string value per setting, so a value is a scalar string by default and the View's typed accessors coerce it (GetInt("server.port") parses "8080"). A value that is itself a JSON or YAML document is decoded into a subtree when a codec is supplied with WithValueCodec — see the config-azure-appconfig spec, D3.

A setting's identity is (key, label), not key alone. WithLabel scopes a backend to one label, which both bounds the read and pins the write; omitted, the backend uses the store's no-label default. Composing several labels is a consumer concern: stack two backends as two layers and let the Store's precedence and per-key merge compose them (spec D2).

The service is reached through the narrow Store interface, which Wrap adapts from a configured *azappconfig.Client. Injecting the client keeps every credential, endpoint and connection-string decision with the consumer and lets the whole unit suite run against a fake, needing no Azure account.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoCredential reports a nil credential supplied to [FromCredential].
	//
	// 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, and without
	// this guard it would panic at the first request.
	ErrNoCredential = errors.NewSentinel("configazureappconfig.no_credential",
		"no Azure credential supplied; build one with azidentity, or use the ambient rung")

	// ErrNoEndpoint reports a rung called with no store endpoint.
	//
	// Unlike an AWS region there is nothing ambient to fall back on: an Azure
	// credential names a principal and carries no endpoint, so the store must be
	// named explicitly however the credential was obtained.
	ErrNoEndpoint = errors.NewSentinel("configazureappconfig.no_endpoint",
		"no App Configuration endpoint supplied; pass https://<name>.azconfig.io")

	// ErrNoConnectionString reports an empty connection string supplied to
	// [FromConnectionString].
	ErrNoConnectionString = errors.NewSentinel("configazureappconfig.no_connection_string",
		"no connection string supplied; read it from the store's access keys")
)

Functions

func FromClient

func FromClient(client *azappconfig.Client, prefix string, opts ...Option) config.Backend

FromClient is the common path: New over a Wrap-ped App Configuration client, so a consumer writes configazureappconfig.FromClient(client, "app/") without touching the narrow interface.

Example

ExampleFromClient reads configuration from an Azure App Configuration store. You build and configure the client — here from a connection string, though a managed identity or service principal via azidentity is the common path — and hand it in with a prefix that scopes and is stripped from the keys, so app/server/port in the store reads back as server.port. The credential is yours: azidentity is your dependency, not this module's.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2"
	"gitlab.com/phpboyscout/go/config"

	configazureappconfig "gitlab.com/phpboyscout/go/config-azure-appconfig"
)

func main() {
	client, err := azappconfig.NewClientFromConnectionString(os.Getenv("APPCONFIG_CONNECTION_STRING"), nil)
	if err != nil {
		log.Fatal(err)
	}

	store, err := config.NewStore(context.Background(),
		config.WithBackend(configazureappconfig.FromClient(client, "app/")),
	)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(store.View().GetInt("server.port"))
}

func FromConnectionString added in v0.3.0

func FromConnectionString(conn, prefix string, opts ...Option) (config.Backend, error)

FromConnectionString builds the client from one of the store's access keys.

It sits alongside FromCredential rather than beneath it because App Configuration genuinely has two injection shapes: a connection string carries the endpoint AND the secret together, where a credential names a principal and needs the endpoint supplied separately. Neither is a special case of the other.

The connection string is a SECRET — it embeds the access key. It is never logged here, and a caller should treat it as they would a password: out of source control, out of process listings, and read from wherever their other secrets live.

func FromCredential added in v0.3.0

func FromCredential(
	cred azcore.TokenCredential, endpoint, prefix string, opts ...Option,
) (config.Backend, error)

FromCredential builds the App Configuration client from a credential the caller holds and returns a backend over the prefix.

azappconfig.NewClient does no I/O — it errors only on a malformed endpoint — so this rung constructs eagerly and reaching the store stays deferred to Load, which has a context to bound it.

Where the ambient rung is

Deliberately NOT here. Resolving the ambient Azure identity chain costs this module seven further dependencies, and charging every consumer for an identity graph they may never use would spend the dependency-footprint guarantee this adapter states in its own test. It lives in the ambient subpackage instead:

import acambient "gitlab.com/phpboyscout/go/config-azure-appconfig/ambient"

b, err := acambient.Default(ctx, "https://my-store.azconfig.io", "app/")

To share ONE resolved credential across several adapters, resolve it with go/azureclient and pass it here (spec 0012 L-4, L-5).

func New

func New(store Store, prefix string, opts ...Option) config.Backend

New returns a backend contributing the App Configuration settings under prefix as a config layer. store is the injected client — a fake in tests, a Wrap-ped *azappconfig.Client in production; prefix scopes and is stripped from the keys.

Types

type Option

type Option func(*backend)

Option configures a backend.

func WithLabel

func WithLabel(label string) Option

WithLabel scopes the backend to label: the read lists only settings with that label, and a write pins new settings to it. Omitted, the backend uses the store's no-label default. One label per backend instance — composing several is a consumer concern, done by stacking backends as layers (spec D2 / Resolved §1).

func WithSentinelKey

func WithSentinelKey(key string) Option

WithSentinelKey makes Watch poll a single sentinel setting by conditional GET on its ETag: App Configuration returns 304 while the sentinel is unchanged and the new setting when it moves, at which point onChange fires and the Store re-reads. This is Azure's own recommended refresh mechanism and keeps the steady-state cost to one conditional request per interval. Omitted, Watch falls back to re-listing the whole prefix each interval and firing when any setting's ETag changed — correct, but heavier (spec D7).

func WithValueCodec

func WithValueCodec(codec config.Codec) Option

WithValueCodec decodes each setting's value through codec: a value that decodes to a mapping becomes a subtree at its key's path, and a value the codec rejects — a bare scalar, or bytes that are not a document — stays a scalar string, so a prefix mixing flat keys and object blobs reads correctly. Omitted, every value is a scalar string.

codec is any config.Codec — the interface the sibling format adapters implement — so a JSON-blob store is read with configjson.Codec{} and a YAML-blob store with the core's, and this module takes no codec dependency of its own. A setting's declared content type is never used to pick a codec (spec D3 / Resolved §3); a Key Vault reference is always left as an opaque string, codec or no codec.

Writes always target flat settings; writing into a value that a codec decoded from a blob is not supported and lands as a sibling flat setting.

type Setting

type Setting struct {
	Key         string
	Label       string
	Value       string
	ContentType string
	ETag        string // opaque; azcore.ETag rendered to string
}

Setting is one App Configuration setting: its identity (key, label), its string value, its declared content type, and the ETag the write and watch paths compare against.

type Store

type Store interface {
	// List returns every setting whose key matches keyFilter and whose label is
	// label, walking the SDK pager to completion, each with the ETag identifying
	// its state at this read. keyFilter is App Configuration's server-side filter
	// (e.g. "app/*"); label is the exact label ("" for the store's no-label
	// default).
	List(ctx context.Context, keyFilter, label string) ([]Setting, error)

	// Get fetches one setting. With a non-empty sinceETag it reports
	// changed=false when the setting is unchanged since sinceETag (the App
	// Configuration conditional-GET / 304 path); with an empty sinceETag it is an
	// unconditional read reporting changed=true. An absent setting reports
	// changed=false with a zero Setting and a nil error. Used by [Verify]
	// (unconditional) and the sentinel-key watch (conditional, spec D7).
	Get(ctx context.Context, key, label, sinceETag string) (s Setting, changed bool, err error)

	// Set writes value at (key, label). etag empty means create-if-absent; a
	// non-empty etag means overwrite only if the store's ETag still matches
	// (If-Match). ok is false, with a nil error, when the ETag no longer matches —
	// the setting moved since Load.
	Set(ctx context.Context, key, label, value, contentType, etag string) (ok bool, err error)

	// Delete removes (key, label) only if the store's ETag still matches. ok is
	// false, with a nil error, when it moved.
	Delete(ctx context.Context, key, label, etag string) (ok bool, err error)
}

Store is the slice of App Configuration this adapter uses, behind an interface it owns so a fake drives the unit suite and the real client is adapted by Wrap.

func Wrap

func Wrap(client *azappconfig.Client) Store

Wrap adapts a configured App Configuration SDK client to the narrow Store interface. The client carries every credential, endpoint and connection-string decision; Wrap adds none — it only translates calls. It never touches azidentity: building the credential is the consumer's job (spec D6).

Directories

Path Synopsis
Package ambient adds the zero-conf rung to config-azure-appconfig.
Package ambient adds the zero-conf rung to config-azure-appconfig.

Jump to

Keyboard shortcuts

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