configconsul

package module
v0.3.2 Latest Latest
Warning

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

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 11 Imported by: 0

README

config-consul

Read and write configuration from HashiCorp Consul 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 HashiCorp Consul is a sibling backend module like this one, so a consumer who configures from Consul takes it and one who does not pays nothing for it.

You build and configure the Consul client — that is where every address, token, TLS and datacenter decision lives — and hand it in. The adapter takes a prefix that scopes and is stripped from the keys:

import (
	capi "github.com/hashicorp/consul/api"
	"gitlab.com/phpboyscout/go/config"
	configconsul "gitlab.com/phpboyscout/go/config-consul"
)

client, _ := capi.NewClient(capi.DefaultConfig())

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

A Consul layer takes part in precedence, per-key merge, provenance and hot-reload exactly as a file does. Keys 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

Values: strings, or decoded documents

Consul stores bytes, 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 a 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"

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

A value that decodes to an object becomes a subtree; a value that is a bare scalar stays a string, so a prefix mixing flat keys and document blobs reads correctly. You inject the one format your Consul actually holds, so this module takes no codec dependency of its own.

Injecting the client

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

What it costs

The config graph plus the Consul SDK (github.com/hashicorp/consul/api and its client dependencies) — the honest cost of talking to Consul, asserted by an allowlist test so an unforeseen transitive addition fails the build rather than arriving quietly. The testcontainers-go integration suite is a test-only dependency and reaches no consumer.

License

See LICENSE.

Documentation

Overview

Package configconsul contributes configuration from a HashiCorp Consul key-value 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 prefix scopes the backend to one namespace of the KV store; the keys beneath it, split on "/", become the layer's nested tree. Consul stores bytes, 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-consul spec, D3.

The service is reached through the narrow KV interface, which Wrap adapts from a configured *api.Client. Injecting the client keeps every credential, address and TLS decision with the consumer and lets the whole unit suite run against a fake, needing no Consul.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNoConfig = errors.NewSentinel("configconsul.no_config",
	"no Consul config supplied; pass one, or use Default to read the ambient environment")

ErrNoConfig reports a nil config supplied to FromConfig.

Functions

func Default added in v0.3.0

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

Default builds a backend from the ambient Consul environment — CONSUL_HTTP_ADDR, CONSUL_HTTP_TOKEN, CONSUL_HTTP_SSL and the rest — as capi.DefaultConfig reads them.

This is the zero-conf rung: the casual consumer who wants their tool to read configuration from the Consul the rest of their tooling already talks to, without wiring a client first.

The defaults are Consul's own, not this adapter's. With nothing set, DefaultConfig resolves 127.0.0.1:8500, which is what every other Consul client on the machine does — adopting a provider's documented default is not the same act as inventing one, which is why config-etcd has no rung here at all (spec 0012 R1).

Nothing is contacted at construction; the first Load reaches the agent, and an unreachable one is an ordinary error from there.

The token is read once, and this rung does not renew it

capi.NewClient resolves the ACL token a single time and copies it onto the client, where every request reads it. Nothing re-reads the environment afterwards — and CONSUL_HTTP_TOKEN_FILE is read once too, at construction, not per request. The SDK's own comment on that path says derived clients are expected to re-read the file; a backend built here never becomes a derived client.

Consul ACL tokens may carry an expiry (capi.ACLToken has ExpirationTTL and ExpirationTime). Where one does, a long-lived process reloading configuration fails every Load once it lapses, with no path back — the backend cannot learn its credential expired, and re-reading a rotated token file is exactly what it does not do.

That is fine for a command. For a long-lived process with an expiring token, build the client yourself and rebuild it when the token rotates, then use FromClient. A token with no expiry set is unaffected.

func FromClient

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

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

Example

ExampleFromClient reads configuration from a Consul KV store. You build and configure the Consul client — address, token, TLS all stay yours — and hand it in with a prefix that scopes and is stripped from the keys, so app/server/port in Consul reads back as server.port.

package main

import (
	"context"
	"fmt"
	"log"

	capi "github.com/hashicorp/consul/api/v2"
	"gitlab.com/phpboyscout/go/config"

	configconsul "gitlab.com/phpboyscout/go/config-consul"
)

func main() {
	client, err := capi.NewClient(capi.DefaultConfig())
	if err != nil {
		log.Fatal(err)
	}

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

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

func FromConfig added in v0.3.0

func FromConfig(cfg *capi.Config, prefix string, opts ...Option) (config.Backend, error)

FromConfig builds the Consul client from a config the caller assembled — a specific address, datacenter, token or TLS setup — and returns a backend over it.

capi.NewClient does no network I/O: it validates the config and constructs an HTTP client, so a failure here is a malformed configuration rather than an unreachable Consul. Reaching the agent is deferred to Load, which has a context to bound it.

func New

func New(kv KV, prefix string, opts ...Option) config.Backend

New returns a backend contributing the Consul KV under prefix as a config layer. kv is the injected client — a fake in tests, a Wrap-ped *api.Client in production; prefix scopes and is stripped from the keys.

Types

type KV

type KV interface {
	// List returns every pair under prefix and the index identifying that read.
	// The index is what the write and watch paths compare against, so it must be
	// the one Consul reports for the read, not a fresh fetch.
	List(ctx context.Context, prefix string) (pairs []Pair, index uint64, err error)

	// Watch blocks until prefix changes past index or wait elapses, returning the
	// new index. A timeout with no change returns the same index and a nil error.
	Watch(ctx context.Context, prefix string, index uint64, wait time.Duration) (uint64, error)

	// Commit applies ops as one atomic compare-and-swap transaction. ok is false,
	// with a nil error, when the CAS failed because a key moved since Load.
	Commit(ctx context.Context, ops []Op) (ok bool, err error)
}

KV is the slice of Consul 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 *capi.Client) KV

Wrap adapts a configured Consul SDK client to the narrow KV interface. The client carries every credential, address, TLS and datacenter decision; Wrap adds none — it only translates calls.

type Op

type Op struct {
	Key    string
	Value  []byte // ignored when Delete
	Index  uint64
	Delete bool
}

Op is one write in a commit: a set or a delete of a full Consul key, guarded by the ModifyIndex the key had at Load. Index zero requires the key be absent — a create — so a stale write is rejected rather than overwriting a change.

type Option

type Option func(*backend)

Option configures a backend.

func WithValueCodec

func WithValueCodec(codec config.Codec) Option

WithValueCodec decodes each Consul 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.

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

type Pair

type Pair struct {
	// Key is the full Consul key, prefix included.
	Key string
	// Value is the raw bytes Consul holds.
	Value []byte
	// ModifyIndex is the key's version, for compare-and-swap on write.
	ModifyIndex uint64
}

Pair is one Consul key/value, with the modify index the write path uses for compare-and-swap.

Jump to

Keyboard shortcuts

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