Skill 08 · Provider Configuration
Subchapter 8.2
references/credential-chain.mdMarkdown18 KBView on GitHub
A full, compilable credential chain for a fictional examplecloud provider.
Everything lives in one package, internal/credentials/, so it can be unit
tested without any Terraform machinery. Adapt names, file formats, and the
set of sources to your API’s ecosystem — the structure is what transfers.
provider.gochain.gostatic.goenv.gofile.goresolve.gointernal/credentials/
├── provider.go # Provider interface, Credentials, ErrNoCredentials, ChainError
├── chain.go # Chain: ordered resolution, error aggregation
├── static.go # source 1: provider block values
├── env.go # source 2: environment variables
├── file.go # source 3: shared credentials file profiles
├── resolve.go # NewDefaultChain: assembles the canonical order
└── *_test.goprovider.go:
package credentials
import (
"context"
"errors"
"fmt"
"strings"
)
// ErrNoCredentials signals that a source had nothing to offer and the chain
// should fall through to the next source. Any other error from Retrieve
// means the source was configured but unusable (malformed file, missing
// profile) and must be surfaced to the user, not silently skipped.
var ErrNoCredentials = errors.New("no credentials found")
// Credentials is a complete set of secrets. Secrets are resolved as a set:
// a source that supplies only one of the two fields supplies nothing.
type Credentials struct {
APIKey string
APISecret string
Source string // name of the provider that supplied them
}
func (c Credentials) Complete() bool {
return c.APIKey != "" && c.APISecret != ""
}
// String and GoString redact the secret so %v, %+v, and %#v can never leak
// it into logs, diagnostics, or wrapped errors.
func (c Credentials) String() string {
return fmt.Sprintf("Credentials{APIKey: %s, APISecret: ***REDACTED***, Source: %s}", c.APIKey, c.Source)
}
func (c Credentials) GoString() string { return c.String() }
// Provider is one source of credentials. Retrieve returns ErrNoCredentials
// (possibly wrapped) when the source has nothing to offer.
type Provider interface {
Retrieve(ctx context.Context) (Credentials, error)
Name() string
}
// ChainError aggregates the outcome of every source the chain consulted, so
// the final diagnostic can show users exactly what was tried and why each
// source was skipped.
type ChainError struct {
attempts []attempt
}
type attempt struct {
source string
err error
}
func (e *ChainError) record(source string, err error) {
e.attempts = append(e.attempts, attempt{source: source, err: err})
}
func (e *ChainError) Error() string {
if len(e.attempts) == 0 {
return ErrNoCredentials.Error()
}
var b strings.Builder
b.WriteString("no valid credential sources found. Sources tried:")
for _, a := range e.attempts {
fmt.Fprintf(&b, "\n - %s: %s", a.source, a.err)
}
return b.String()
}
// Is makes errors.Is(err, ErrNoCredentials) true only when every source
// fell through cleanly. If any source failed hard (e.g. malformed file),
// the caller should show that failure instead of the generic
// "no credentials" guidance.
func (e *ChainError) Is(target error) bool {
if target != ErrNoCredentials {
return false
}
for _, a := range e.attempts {
if !errors.Is(a.err, ErrNoCredentials) {
return false
}
}
return true
}Design notes:
Complete() checking one field.Source exists purely for observability — log it, never the secrets.ChainError.Is is what lets Configure choose between the “here is how
to supply credentials” message and the “your credentials file is broken”
message with one errors.Is call.chain.go:
package credentials
import "context"
// Chain consults providers in order and returns the first complete set of
// credentials. Every skipped source is recorded so the aggregate error can
// explain the full resolution attempt.
type Chain struct {
providers []Provider
}
func NewChain(providers ...Provider) *Chain {
return &Chain{providers: providers}
}
func (c *Chain) Retrieve(ctx context.Context) (Credentials, error) {
chainErr := &ChainError{}
for _, p := range c.providers {
creds, err := p.Retrieve(ctx)
switch {
case err != nil:
// Record and continue: a broken source should not mask a
// working one later in the chain, but it must appear in the
// final error if nothing works. (Alternative: fail fast on
// non-sentinel errors. Continue-and-record is friendlier when
// e.g. a stale credentials file exists but env vars are set.)
chainErr.record(p.Name(), err)
case !creds.Complete():
chainErr.record(p.Name(), ErrNoCredentials)
default:
creds.Source = p.Name()
return creds, nil
}
}
return Credentials{}, chainErr
}
func (c *Chain) Name() string { return "Chain" }The chain itself implements Provider, so chains compose: a platform
identity source that is itself a chain of metadata endpoints slots in as one
entry.
static.go — values from the provider block. Highest priority: explicit
configuration always wins.
package credentials
import "context"
type StaticProvider struct {
APIKey string
APISecret string
}
func (p *StaticProvider) Retrieve(_ context.Context) (Credentials, error) {
creds := Credentials{APIKey: p.APIKey, APISecret: p.APISecret}
if !creds.Complete() {
return Credentials{}, ErrNoCredentials
}
return creds, nil
}
func (p *StaticProvider) Name() string { return "provider configuration" }env.go — the injectable GetEnv field is what makes precedence unit
tests hermetic (no os.Setenv cross-test contamination).
package credentials
import (
"context"
"os"
)
const (
EnvAPIKey = "EXAMPLECLOUD_API_KEY"
EnvAPISecret = "EXAMPLECLOUD_API_SECRET"
EnvProfile = "EXAMPLECLOUD_PROFILE"
EnvCredsFile = "EXAMPLECLOUD_SHARED_CREDENTIALS_FILE"
)
type EnvProvider struct {
// GetEnv defaults to os.Getenv; inject a map-backed func in tests.
GetEnv func(string) string
}
func (p *EnvProvider) getenv(key string) string {
if p.GetEnv != nil {
return p.GetEnv(key)
}
return os.Getenv(key)
}
func (p *EnvProvider) Retrieve(_ context.Context) (Credentials, error) {
creds := Credentials{
APIKey: p.getenv(EnvAPIKey),
APISecret: p.getenv(EnvAPISecret),
}
if !creds.Complete() {
return Credentials{}, ErrNoCredentials
}
return creds, nil
}
func (p *EnvProvider) Name() string {
return "environment variables (" + EnvAPIKey + ", " + EnvAPISecret + ")"
}Naming the actual variables in Name() pays off directly in the aggregate
error message.
file.go. The format here is minimal INI-style parsing to avoid
dependencies; use YAML/TOML if your ecosystem prefers it. The error
semantics are the part to copy exactly:
The rule is uniform: a defaulted value that resolves to nothing falls through; an explicit value that resolves to nothing is a user mistake and errors. It applies identically to the file path and the profile name.
| Condition | Behavior | Why |
|---|---|---|
| File absent, path defaulted | ErrNoCredentials | Most users have no file; fall through silently |
| File absent, path set explicitly | hard error | The user pointed at it; tell them it is missing |
| File unreadable or malformed | hard error | Never silently skip a file the user wrote |
| Profile missing, name set explicitly | hard error | An explicit profile that resolves to nothing is a typo |
| Profile missing, name defaulted | ErrNoCredentials | A file holding only named profiles shouldn’t break users who never asked for default |
| Profile present, fields incomplete | ErrNoCredentials | The profile may intentionally hold only non-secret settings |
package credentials
import (
"bufio"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
const DefaultProfile = "default"
resolve.go — one constructor owns the canonical order so Configure and
tests can never disagree about precedence.
package credentials
import "os"
type Options struct {
FilePath string // explicit credentials_file from provider config
Profile string // explicit profile from provider config
GetEnv func(string) string
// DefaultFilePath overrides the default file location when the user set
// nothing (keeps defaulted fall-through semantics). Tests use this to
// stay hermetic without hand-assembling the chain.
DefaultFilePath string
}
// NewDefaultChain assembles the canonical resolution order:
// static provider configuration > environment variables > credentials file.
// Add a platform identity provider at the end where the platform offers one.
func NewDefaultChain(staticKey, staticSecret string, opts Options) *Chain {
getenv := opts.GetEnv
if getenv == nil {
getenv = os.Getenv
}
path, pathExplicit := ResolveFilePath(opts.FilePath, getenv)
if !pathExplicit && opts.DefaultFilePath != "" {
path = opts.DefaultFilePath
}
profile, profileExplicit := ResolveProfile(opts.Profile, getenv)
return NewChain(
&StaticProvider{APIKey: staticKey, APISecret: staticSecret},
&EnvProvider{GetEnv: getenv},
&FileProvider{Path: path, PathExplicit: pathExplicit, Profile: profile, ProfileExplicit: profileExplicit},
)
}For Configure wiring — unknown-value guards, the errors.Is branch that
selects the right diagnostic, and the permission warning — see the skill
body (SKILL.md); it composes directly with this package.
The essential coverage, hermetic via injected env and t.TempDir():
package credentials
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func mapEnv(m map[string]string) func(