Appearance
internal/platform/secretstore
internal/platform/secretstore is the sole sanctioned wrapper around OpenBao (Vault-API-compatible) for plexsphere. Direct imports of github.com/openbao/openbao/api from any bounded context are rejected by the no-direct-persistence-from-contexts depguard rule in .golangci.yml.
This document is the authoritative reference for:
- Three Auth modes
- Dev-token build-tag invariant
- Client Config
- KV v2 CAS semantics
- Renewal goroutine contract
- Readiness probe
- Raft snapshot
See also:
- README § Storage Topology — Secret backend
- README § Platform Secret Backend (OpenBao / Vault-compatible)
- docs/architecture/storage-topology.md
Three Auth modes
The secretstore.Auth interface is sealed: its methods are unexported by design so only this package can provide a concrete implementation. A binary cannot be linked against a third-party login method that would bypass the workload-identity requirement.
| Mode | Type | Production? | Required fields | Default mount |
|---|---|---|---|---|
| AppRole | AuthAppRole | Yes | RoleID, SecretID | approle (override via MountPath) |
| Kubernetes | AuthKubernetes | Yes | Role | kubernetes (override via MountPath) |
| Dev Token | AuthDevToken | No — build-tag only | Token | — |
AuthAppRole
go
cfg := secretstore.Config{
Address: "https://openbao.internal:8200",
Auth: secretstore.AuthAppRole{
RoleID: os.Getenv("OPENBAO_APPROLE_ROLE_ID"),
SecretID: os.Getenv("OPENBAO_APPROLE_SECRET_ID"),
},
}RoleID and SecretID are both required; missing either returns an error tagged (REQ-004, PX-0003). SecretID is treated as sensitive and is never echoed in errors or logs.
AuthKubernetes
go
cfg := secretstore.Config{
Address: "https://openbao.internal:8200",
Auth: secretstore.AuthKubernetes{
Role: "plexsphere-core",
// Defaults to the projected SA token at
// /var/run/secrets/kubernetes.io/serviceaccount/token
},
}The default projected service-account token path matches the Kubernetes convention; override only for test rigs.
Dev-token build-tag invariant
AuthDevToken exists only behind the //go:build secretstore_dev build tag. This is an invariant, not a preference:
internal/platform/secretstore/auth_devtoken.gois gated on//go:build secretstore_dev. When compiled in,init()emits a warning-level slog entry so operators cannot miss that the build contains the static-token code path.internal/platform/secretstore/auth_devtoken_stub.gois gated on//go:build !secretstore_devand deliberately does not defineAuthDevToken. Referring toAuthDevTokenoutside the dev build is therefore a compile-time error, enforced by construction.
A production binary compiled without the secretstore_dev tag cannot contain the static-token code path, even if a future refactor accidentally referenced it.
Client Config
| Field | Type | Default | Purpose |
|---|---|---|---|
Address | string | — (REQUIRED) | OpenBao cluster URL. Missing → error tagged (REQ-007, PX-0003). |
Auth | Auth | — (REQUIRED) | Authentication strategy. Missing → error tagged (REQ-004, PX-0003). |
Namespace | string | "" | Optional OpenBao namespace; sets the X-Vault-Namespace header. |
TLSInsecureSkipVerify | bool | false | Local-dev-only TLS bypass. Production deployments MUST leave this false. |
Constructing a client
NewClient(ctx, cfg, logger) (*Client, error) validates the config, performs the initial login, and starts the renewal goroutine. Callers MUST invoke client.Close() at shutdown to stop the renewal loop cleanly.
go
client, err := secretstore.NewClient(ctx, cfg, logger)
if err != nil { /* ... */ }
defer client.Close()Error strings never embed SecretID, AppRole credentials, tokens, or any other secret material.
KV v2 CAS semantics
(*Client).KVGet(ctx, mount, path) and (*Client).KVPut(ctx, mount, path, data, cas) are the sanctioned accessors for the KV v2 engine.
| Call | Return | Semantics |
|---|---|---|
KVGet | *KVSecret, error | Reads the latest version. A missing secret surfaces the underlying API error so callers can distinguish 404 from transport failures. |
KVPut(..., cas: nil) | *KVSecret, error | Unconditional write. |
KVPut(..., cas: &n) | *KVSecret, error | Check-and-set gated on version n. On mismatch, returns a descriptive error tagged cas mismatch ... (REQ-004, PX-0003). |
CAS-mismatch handling
A CAS mismatch is reported without internal retry. Retrying a CAS write without rereading would lose data — callers must KVGet to refresh the expected version before attempting KVPut again. The mismatch detector matches either "cas mismatch" or "check-and-set parameter did not match" in the underlying error (case-insensitive).
Package-level KVSecret type
The KVSecret value returned by KVGet / KVPut is a package-level struct intentionally narrower than the underlying *api.KVSecret:
| Field | Type | Source |
|---|---|---|
Data | map[string]any | The key/value payload of the secret version. |
Version | int | KV v2 version number. |
Metadata | map[string]any | Raw KV v2 custom metadata; may be nil. |
Bounded contexts depend on KVSecret, never on *api.KVSecret directly, so the OpenBao SDK stays contained inside this package .
Renewal goroutine contract
NewClient starts a background goroutine (runRenewalLoop) that:
- Refreshes the vault token at approximately 2/3 of its lease duration (
renewInterval(leaseDuration)). - If the lease is renewable, calls
RenewSelfand resets the timer against the freshLeaseDuration. On a renewable lease that successfully renews, the client continues without a fresh login. - If renewal fails (renewal error, non-renewable lease, empty response), performs a fresh
loginvia the configuredAuthand resets the timer against the new lease.
The loop terminates when either the caller-supplied context cancels or Close is invoked. Close signals the loop via an internal closeCh and waits on renewDone for shutdown to complete .
Failure semantics
- A re-login failure is logged at error level and the loop retries on the next tick. The client continues to serve KV calls using the currently-held token until it expires — a sealed or unreachable OpenBao is surfaced on
/readyzvia the probe below, not by crashing the client. - Error strings logged by the renewal loop never embed credential material.
Readiness probe
secretstore.ProbeFunc(client) returns a health.ProbeFunc that calls Sys().Health() on the wrapped vault client and flags the dependency unhealthy when the cluster is sealed or not initialized, both of which are fatal to KV operations.
| Symbol | Value |
|---|---|
secretstore.ProbeName | "secretstore" |
| Error suffix | (REQ-006, PX-0003) |
| Failure classes | nil client, ctx.Err(), HTTP error, empty response, sealed, not initialized |
go
registry.Register(secretstore.ProbeName, secretstore.ProbeFunc(client))Error strings never expose the OpenBao address, namespace, or any token material; /readyz bodies are served to unauthenticated probes.
Raft snapshot
(*Client).RaftSnapshot and (*Client).RaftSnapshotRestore wrap the integrated-raft snapshot surface of an OpenBao cluster. A snapshot is a gzipped tar that captures a point-in-time copy of the entire keyspace plus the raft state, suitable for disaster-recovery backups. Restore replays such a snapshot back into a target cluster.
| Method | Return | Semantics |
|---|---|---|
RaftSnapshot(ctx, w io.Writer) | error | Streams a snapshot of the cluster into w. On a dev/inmem backend it fails with ErrRaftUnsupported; any other failure is wrapped with %w so the cause stays reachable via errors.As. |
RaftSnapshotRestore(ctx, r io.Reader, force bool) | error | Restores a previously captured snapshot from r. With force == true the restore bypasses the cluster-state safety checks (the force endpoint). Errors are wrapped exactly as in RaftSnapshot. |
ErrRaftUnsupported classification
Both methods return the package sentinel ErrRaftUnsupported (secretstore: raft storage not in use) when the backing OpenBao node is not running the integrated raft storage backend. Callers use errors.Is(err, secretstore.ErrRaftUnsupported) to tell "this backend can never snapshot" apart from a transient transport or network failure that may succeed on retry.
The classifier keys on the stable server phrase "raft storage is not in use", not on an HTTP status code: the dev-mode status code varies across OpenBao versions while the message text is stable. When the sentinel is returned, the underlying *api.ResponseError is still reachable via errors.As because the error is wrapped with both the sentinel and the cause.
Dev-mode limitation
The dev-stack / StartOpenBao dev fixture runs in-memory storage and therefore cannot produce or consume raft snapshots — every snapshot or restore call against it returns ErrRaftUnsupported. Only a node booted with the integrated raft storage backend supports snapshots; use the StartOpenBaoRaft integration fixture (or a production raft deployment) when exercising the snapshot path.
Seal-key custody implications
A snapshot-force restore overwrites the target node's data, but the restored cluster continues to use the source cluster's keyring. The practical consequence for operators:
- The restored node must be unsealed with the original (snapshot-time) unseal/seal key, not the target node's current key.
- After a cross-cluster force restore, plan the unseal step around the source cluster's seal-key custody. A restore that succeeds at the API level still leaves the node sealed until it is unsealed with the snapshot-time key material.
Snapshot to a file
go
f, err := os.Create("/var/backups/openbao-snapshot.tar.gz")
if err != nil { /* ... */ }
defer f.Close()
if err := client.RaftSnapshot(ctx, f); err != nil {
if errors.Is(err, secretstore.ErrRaftUnsupported) {
// The backend is dev/inmem storage and can never snapshot;
// not a transient failure, so do not retry.
return fmt.Errorf("raft snapshots require the integrated raft backend: %w", err)
}
// Transport/network or other server error — may be retryable.
return err
}Cross-references
../../contributing/layout.md— the bounded-context map locating this package in the codebase.../../../internal/platform/secretstore/— the package source.../index.md— the Reference quadrant index.