Documentation
¶
Overview ¶
Package configgcpsecret contributes secrets from Google Cloud Secret Manager as a first-class config layer: precedence, per-key merge, provenance and safe hot-reload, exactly like a file layer — but marked sensitive, so the core refuses to write a value sourced from here into a plainer layer beneath.
A secret ID may contain only letters, digits, hyphens and underscores, so there is no hierarchy to map: an ID becomes a config key verbatim, hyphens and all. Structure comes from the other mode instead, where one secret holds a whole document.
Reading a project costs one listing plus one request per secret, because the listing carries neither payloads nor version information. That is the service's shape, not this adapter's.
A disabled newest version is served from an older one ¶
`projects/*/secrets/*/versions/latest` is an alias to the most recently CREATED version — not the most recently created ENABLED one. So an operator who adds version 8 and then disables it leaves `latest` pointing at a version that cannot be accessed, even though version 7 holds a working credential.
This adapter falls back: it serves the newest ENABLED version instead. That keeps an application running through a half-completed rotation, and it means the adapter may be serving a credential somebody deliberately withdrew, while the console shows version 8 as current. WithFallbackObserver is how a consumer learns it happened, because provenance cannot carry a per-key fact.
The project is reached through the narrow API interface, which Wrap adapts from a configured *secretmanager.Client. Injecting the client keeps every credential, project and endpoint decision with the consumer — the adapter never authenticates — and lets the whole unit suite run against a fake.
See the config-gcp-secret spec for the decisions behind all of this.
Index ¶
- Constants
- Variables
- func FromClient(client *secretmanager.Client, project, location string, opts ...Option) config.Backend
- func FromClientSecret(client *secretmanager.Client, project, location, id string, codec config.Codec, ...) config.Backend
- func New(api API, opts ...Option) config.Backend
- func NewSecret(api API, id string, codec config.Codec, opts ...Option) config.Backend
- type API
- type Fallback
- type Option
- type OwnedBackend
- type Payload
- type Secret
- type Version
Constants ¶
const DefaultPollInterval = 5 * time.Minute
DefaultPollInterval is how often the backend polls for change when the consumer does not say otherwise.
Five minutes, deliberately slower than most of this family. A poll costs one listing plus one metadata read per secret, so a sixty-second cadence over a fifty-secret project would be fifty-one requests a minute, indefinitely.
const SourceKind = config.SourceKind("gcp-secret")
SourceKind is what a layer from this backend reports as, so provenance can name Secret Manager as the origin of a value.
Variables ¶
var ErrChecksumMismatch = errors.NewSentinel("config-gcp-secret.checksum_mismatch", "configgcpsecret: payload checksum mismatch")
ErrChecksumMismatch reports that a payload's bytes did not match the checksum the service stored with them.
It fails the read rather than skipping the secret, which is the opposite of how a withdrawn version or a binary payload is treated. Those are cases where the store is saying something coherent; a checksum mismatch means the adapter cannot trust what it received, and there is no honest way to carry on past that. A silently corrupted password does not fail here — it fails as an authentication error somewhere downstream, minutes later, at maximum distance from its cause.
var ErrNoProject = errors.NewSentinel("configgcpsecret.no_project",
"no Google Cloud project supplied; pass the project the secrets live in")
ErrNoProject reports a rung called with no project id. Unlike a region on AWS, there is no ambient convention that can supply one: Application Default Credentials authenticate a principal, they do not name the project whose secrets to read.
Functions ¶
func FromClient ¶
func FromClient( client *secretmanager.Client, project, location string, opts ...Option, ) config.Backend
FromClient returns a backend reading a project's secrets through a configured client — the convenience path over New and Wrap.
client must already carry its credentials and its endpoint. This adapter never authenticates and never resolves a credential: application default credentials, workload identity, a service account key or an impersonated principal all work here precisely because the adapter knows about none of them.
location is the region for a regionalised secret, or empty for the ordinary project-level parent. The adapter takes it and trusts the client's endpoint to match; it validates neither, because that is the consumer's to configure, and a mismatch surfaces naturally as a NotFound.
func FromClientSecret ¶
func FromClientSecret( client *secretmanager.Client, project, location, id string, codec config.Codec, opts ...Option, ) config.Backend
FromClientSecret returns a backend reading ONE secret, whose payload is a whole document, through a configured client — the convenience path over NewSecret and Wrap. The same client rules as FromClient apply.
func New ¶
New returns a backend contributing a project's secrets as one config layer, each secret's ID becoming a key verbatim.
api is the injected client: a fake in tests, a Wrap-ped *secretmanager.Client in production.
func NewSecret ¶
NewSecret returns a backend contributing ONE secret, whose payload is a whole document, as a config layer.
This is how structure is expressed against a store whose IDs cannot carry it: the tree comes from the document rather than from the key space. It also costs a single request, where reading a project costs one per secret.
The codec is a parameter rather than an option because it is not optional here — a payload is one opaque blob, so without something to decode it there is no tree to contribute, and expressing a non-optional dependency as an Option would only move the failure from compile time to run time.
The rules that SKIP a secret in flat mode FAIL the Load here, for the same reason: the consumer named one secret as their entire configuration source, so an empty layer with no explanation is worse than a refusal. That applies to a secret with no enabled version and to a payload that is not valid UTF-8.
Types ¶
type API ¶
type API interface {
// Access reads one secret's payload at the given version ("" means
// "latest"). It returns the resolved version resource name alongside the
// bytes, so a single call yields both the value and the watch's change
// marker.
//
// A secret or version that does not exist returns fs.ErrNotExist. A version
// that exists but is not ENABLED returns some other error, deliberately not
// one this adapter matches on: usability is established from Describe's
// State field, because a field is a contract and an error code is a hope.
Access(ctx context.Context, id, version string) (Payload, error)
// Describe reads one version's metadata — its resolved name and state —
// without the payload. It drives the poll and the fallback resolution, and
// it succeeds for a version that Access would refuse.
Describe(ctx context.Context, id, version string) (Version, error)
// Versions lists a secret's versions, newest first, for the fallback walk.
// It is called only once Describe has reported that latest is not ENABLED,
// so the common path never pays for it.
Versions(ctx context.Context, id string) ([]Version, error)
// List returns every secret's metadata under the configured parent,
// narrowed by filter server-side. It carries neither payloads nor version
// information — the service does not include them.
List(ctx context.Context, filter string) ([]Secret, error)
}
API is the slice of Secret Manager this adapter uses, behind an interface it owns so a fake drives the unit suite and the real client is adapted by Wrap.
Read-only: there is no write method, because writing a secret from a configuration library waits for a spec that justifies it.
func Wrap ¶
func Wrap(client *secretmanager.Client, project, location string) API
Wrap adapts a real Secret Manager client to the narrow API interface.
The two parent forms differ in shape rather than in value: a global secret is projects/{project}/secrets/{id}, while a regionalised one is projects/{project}/locations/{location}/secrets/{id}. The location segment is ABSENT for the ordinary case rather than being the literal "global", so location == "" means the project-level parent.
type Fallback ¶
type Fallback struct {
// ID is the secret ID, and the config key.
ID string
// LatestVersion is the resource name `latest` resolved to.
LatestVersion string
// LatestState is that version's state: DISABLED or DESTROYED.
LatestState string
// ServedVersion is the resource name of the version actually served, or
// empty when no version was enabled at all and the secret was skipped.
ServedVersion string
}
Fallback reports one secret whose latest version was not usable.
It is this module's answer to a limitation it cannot fix. config.Source has four fields and is per-layer, while a fallback is per-key — in flat mode one layer carries every secret in the project, so no single string could say which of them fell back. Origin and Explain both return a Source, so neither can surface it either. A callback at the moment it happens is the next best thing, and is offered as that rather than as an equivalent.
type Option ¶
type Option func(*backend)
Option configures a backend.
func WithFallbackObserver ¶
WithFallbackObserver reports every secret that did not resolve to `latest`.
This is the mitigation that made the fallback acceptable rather than a footnote to it. Without it, a rotation that half-completed — the new version written and then withdrawn — keeps the application working while nobody learns the rotation did not finish, and the failure surfaces later when the old credential is revoked in its turn.
fn is called synchronously during Load, on the Store's goroutine, once per affected secret. It must not block and must not call back into the Store. A callback rather than a log line because this module has no logger of its own; wire it to whatever you already alert on.
func WithLabel ¶
WithLabel reads only the secrets carrying label key=value, composing into an ANDed server-side filter.
This is a real capability the Key Vault sibling could not have, and it is the one that matters most on the N+1 path: the filter is applied before the listing leaves the service, so every secret it removes is one payload read never made. Label-scoping is also how GCP teams already partition a shared project.
Repeatable. Two calls narrow to secrets carrying both.
func WithNamePrefix ¶
WithNamePrefix reads only the secrets whose IDs begin with prefix.
Both halves of this are load-bearing. The service's `name:` filter is case-insensitive SUBSTRING containment, not prefix matching — `name:app` matches `legacy-app-token` as readily as `app-db-password` — so the filter is sent as a server-side narrowing HINT and the prefix is then re-checked client-side. The hint saves the per-secret fetches that scale; the re-check keeps the promise this option's name makes.
The prefix is not stripped: a secret ID is a config key verbatim, and removing part of it here would reintroduce, through the back door, exactly the name-rewriting this adapter refuses to do.
func WithPollInterval ¶
WithPollInterval sets how often the backend polls for change. The default is DefaultPollInterval.
func WithValueCodec ¶
WithValueCodec decodes each secret'"'"'s payload through codec in the flat mode: a payload that decodes to a mapping becomes a subtree under the secret'"'"'s own ID, and a payload the codec rejects — a bare password — stays a scalar string, so a project mixing plain secrets and documents reads correctly.
The ID is still the key either way: this adds structure BENEATH a key, never structure between keys, which the naming rules forbid.
Optional here and required in document mode, and the asymmetry is deliberate: there the names supply no structure, so one opaque blob has no tree without a codec, while here a rejected payload can honestly fall back to a scalar.
func WithVersion ¶
WithVersion pins the version read for every secret — a number ("7") or an alias an operator has defined on the secret.
A pinned version is honoured exactly and NEVER falls back. If it is disabled or destroyed, the Load fails. The consumer named a specific version, so silently substituting another would defeat the point of naming it and would turn an explicit instruction into a guess. The asymmetry between `latest` (fall back, and say so) and a pinned version (fail) is exactly the difference between the adapter choosing and the consumer having chosen.
The default is `latest`, which is what a config layer wants: pinning by default would freeze the layer past a rotation.
type OwnedBackend ¶ added in v0.3.0
OwnedBackend is a backend that built its own Secret Manager client and is therefore responsible for closing it.
It exists because secretmanager.NewClient is not like its AWS and Azure counterparts: it opens a gRPC connection, returns an error, and its own documentation says the client "must be Closed when it is done being used to clean up its underlying connections". config.Backend has no Close, and a Store drops a withdrawn backend without teardown, so a client an adapter resolved for itself would otherwise live for the whole process.
A one-shot CLI can ignore Close and let process exit reclaim it. A long-lived service that adds and withdraws backends should not:
b, err := configgcpsecret.Default(ctx, "my-project", "")
if err != nil { return err }
defer b.Close()
store, err := config.NewStore(ctx, config.WithBackend(b))
The rungs that do NOT build a client — New, Wrap and FromClient — return a plain config.Backend, because there the consumer built it and still owns it (spec 0012 L-7, R2).
func Default ¶ added in v0.3.0
Default builds a Secret Manager client from Application Default Credentials — the metadata server, a workload identity, GOOGLE_APPLICATION_CREDENTIALS, or gcloud's own login — and returns a backend that owns it.
This is the zero-conf rung. Unlike its AWS and Azure counterparts it still needs the project, because ADC authenticates a principal without naming the project whose secrets to read; there is no ambient convention that supplies one, and guessing would read another project's secrets.
To share ONE credential across this adapter and its GCP siblings, resolve it with gitlab.com/phpboyscout/go/gcpclient and pass the options it yields to FromOptions — that collapses three credential detections to one.
The returned backend owns the client and must be Closed; see OwnedBackend.
func FromOptions ¶ added in v0.3.0
func FromOptions( ctx context.Context, project, location string, clientOpts []option.ClientOption, opts ...Option, ) (*OwnedBackend, error)
FromOptions builds a Secret Manager client from client options the caller holds — an explicit credentials file, an emulator endpoint, a credential resolved once and shared — and returns a backend that owns it.
location is the regionalised parent, or "" for the ordinary project-level one.
The returned backend owns the client and must be Closed; see OwnedBackend.
func (*OwnedBackend) Close ¶ added in v0.3.0
func (b *OwnedBackend) Close() error
Close releases the Secret Manager client this backend built. It is safe to call more than once.
type Payload ¶
type Payload struct {
// VersionName is the resolved resource name of the version that served
// these bytes. It is the watch's change marker: a rotation moves it, and
// so does a fallback resolving somewhere new.
VersionName string
// Data is the payload. Secret Manager stores bytes with nothing declaring
// whether they are text, so a payload that is not valid UTF-8 contributes
// no key.
Data []byte
// CRC32C is the Castagnoli checksum the service stored with the payload,
// encoded as an int64 for wire compatibility. It is verified when non-nil
// and skipped when nil.
CRC32C *int64
}
Payload is one resolved access.
type Secret ¶
type Secret struct {
// ID is the last path segment, and the config key.
ID string
Labels map[string]string
Annotations map[string]string
Etag string
}
Secret is one listed secret's metadata.
Labels and Annotations are surfaced for the consumer and, for labels, for server-side filtering. Neither ever selects a decoder: a free-text hint is not a contract, and decoding on one would be guessing at a format the store does not enforce.
type Version ¶
type Version struct {
// Name is the resolved resource name — projects/*/secrets/*/versions/7 —
// and never the literal string "latest".
Name string
// State is ENABLED, DISABLED or DESTROYED. Only ENABLED can be accessed.
State string
}
Version is one version's metadata, without its payload.