awsclient

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 16, 2026 License: MIT Imports: 8 Imported by: 0

README

awsclient

Resolve the AWS configuration a service client is built from — once and shared, or afresh per operation, as the caller chooses.

import "gitlab.com/phpboyscout/go/awsclient"

src := awsclient.Ambient()          // resolved lazily, once, on first use

cfg, err := src.AWSConfig(ctx)
client := ssm.NewFromConfig(cfg)     // pure; no I/O, no error, nothing to close

Why it exists

Building an AWS service client is two steps with very different costs. ssm.NewFromConfig(cfg) is pure — no I/O, no error, nothing to close. Getting the aws.Config is the part that walks a credential chain, may hit an SSO redirect or the instance metadata endpoint, and may fail.

Every adapter that needs a client faces the same problem, so it is solved once here instead of thirteen times downstream.

The rungs

Constructor Resolves Use when
FromConfig(cfg) nothing — uses what you give it you already resolved a config: a profile, an assumed role, a client pointed at LocalStack
Ambient() once, lazily, shared anything long-lived — a config store, a service
PerCall() every call, retaining nothing holding credentials between operations would be wrong

All three return the same Source, so switching posture is a one-word change.

PerCall is not a slower Ambient. A signing backend that resolves credentials inside each operation does so deliberately — "it avoids holding AWS credentials longer than the operation requires" — and adopting a memoising source would quietly change that. It also does not collapse concurrent calls into one attempt, because sharing a resolution between callers who asked not to share one is the thing the rung exists to avoid.

Ambient resolves at most once concurrently and — unlike sync.OnceValuesnever caches a failure, so a flap at startup does not wedge the process.

This module never guesses a region

There is no default region here, and there will not be one. A config that names no region names a key in an account nobody chose, so an empty region is ErrNoRegion from the accessor rather than a confusing failure several calls later.

Two shipped modules already disagree about this, each deliberately: encryption-aws-kms sets no default and errors on an empty region; signing-aws-kms defaults eu-west-2 to match its Terraform module. A general-purpose module cannot hold both opinions and should hold neither. A caller that has a region passes it:

awsclient.Ambient(
    awsclient.WithRegion("eu-west-2"),                                   // the common case
    awsclient.WithLoadOptions(config.WithSharedConfigProfile("build")),  // the rest
)

A caller whose region is a user-facing default — a CLI flag, say — keeps that default in its own layer, where the choice has a reason.

Sharing is explicit

Two adapters that each call Ambient() get two independent sources and resolve the chain twice. That is correct: they may deliberately want different profiles, and a hidden process-wide cache would make the first one's transient failure everybody's, invisibly. A caller who wants one chain says so:

src := awsclient.Ambient()

ssmBackend := configawsssm.FromSource(src, "/app")
s3fs       := configawss3.FSFromSource(src, "my-bucket")

Options

Option Effect
WithRegion(r) Region for the ambient chain. An empty string is ignored, so an unset flag cannot clear one another option set.
WithLoadOptions(o...) The SDK's own load options — profile, endpoint resolver, assumed role. Applied after the region, so an explicit config.WithRegion here wins.
WithBuildTimeout(d) Bounds one attempt (default 30s). Per attempt, not a total budget. Cooperative — it bounds the context the loader receives.
WithLifetimeContext(ctx) Scopes an Ambient source's attempts to the owner's life. Ignored by PerCall.
WithLogger(l) DEBUG diagnostics. Off by default.

Logging

Off unless you pass a logger. Records carry only non-secret identifiers — the region, how long resolution took, and the credential provider's type, which is the most useful thing a wedged chain can tell you because it says which link actually won. Credentials are never logged, at any level, and nothing is logged above DEBUG. Both claims have tests.

What it costs

This module carries the AWS credential-resolution graph — that is its entire job, and it is why it exists as a module rather than in each adapter. The exact set is asserted by depfootprint_test.go. An adapter that takes an aws.Config through a narrow interface it declares itself pays none of it.

Specification

org 0003 — provider client modules (P-2, P-3, P-12, P-13, P-14), which applies the connection lifecycle of org 0002 across the estate. The state machine beneath Ambient is clientlifecycle.

Licence

MIT. See LICENSE.

Documentation

Overview

Package awsclient resolves the AWS configuration a service client is built from — once and shared, or afresh per operation, as the caller chooses.

It exists so that resolving the AWS credential chain is written once for the estate rather than in every adapter that needs a client. An adapter takes an aws.Config and calls its own service's NewFromConfig, which does no I/O; the expensive, failure-prone part is getting that config, and that is all this module does.

Choosing a rung

awsclient.FromConfig(cfg)  // you resolved it; used as-is, nothing is loaded
awsclient.Ambient()        // the ambient chain, resolved once and shared
awsclient.PerCall()        // the ambient chain, resolved afresh every time

Ambient is the usual choice for anything long-lived: it resolves lazily on first use, at most once concurrently, and — unlike sync.OnceValues — never caches a failure, so a flap at startup does not wedge the process.

PerCall is for the caller that must not hold credentials between operations. That is a posture, not a slower Ambient: a signing backend that resolves inside each mint does so deliberately, to avoid holding credentials longer than the operation requires, and adopting a memoising source would quietly change its security properties.

All three return the same Source, so switching is a one-word change.

This module never guesses a region

There is no default region here, and there will not be one. A config that names no region names a key in an account nobody chose, so an empty region is ErrNoRegion from the accessor rather than a confusing failure several calls later. A caller that has a region passes it with WithRegion; a caller whose region is a user-facing default — a CLI flag, say — keeps that default in its own layer, where the choice has a reason.

Sharing is explicit

Two adapters that each call Ambient get two independent sources and resolve the chain twice. That is correct: they may deliberately want different profiles, and a hidden process-wide cache would make the first one's transient failure everybody's. A caller who wants one chain builds one Source and hands it to each adapter.

Specified by org 0003 (P-2, P-3, P-12, P-13, P-14), which applies org 0002's connection lifecycle across the estate.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNoRegion = errors.NewSentinel("awsclient.no_region",
	"no AWS region configured; set one on the config, pass WithRegion, or set AWS_REGION")

ErrNoRegion reports a configuration that names no AWS region.

It is returned by FromConfig at construction and by an ambient source's accessor, because a region is not optional: without one the SDK has no endpoint to call and the failure surfaces far from its cause.

Functions

This section is empty.

Types

type Option

type Option func(*options)

Option configures a source.

func WithBuildTimeout

func WithBuildTimeout(d time.Duration) Option

WithBuildTimeout bounds a single resolution attempt. It is per attempt, not a total budget: a failure caches nothing, so a later call gets a fresh allowance.

func WithLifetimeContext

func WithLifetimeContext(ctx context.Context) Option

WithLifetimeContext scopes an Ambient source's resolution attempts to the life of whatever owns it, so shutting that down abandons an attempt in flight. PerCall ignores it, being already scoped to its caller.

func WithLoadOptions

func WithLoadOptions(opts ...func(*awscfg.LoadOptions) error) Option

WithLoadOptions passes the SDK's own load options through to awscfg.LoadDefaultConfig — a shared-config profile, a custom endpoint resolver, an assumed role.

It is the escape hatch for everything WithRegion does not cover. Options accumulate across calls and are applied after the region, so an explicit awscfg.WithRegion here wins.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger enables DEBUG diagnostics. nil disables them, which is the default: a library that logs without being asked writes to somebody else's stderr.

Records carry only non-secret identifiers — the region, the credential provider's type, how long resolution took. Credentials are never logged, at any level, and nothing is logged above DEBUG.

func WithRegion

func WithRegion(region string) Option

WithRegion sets the region the ambient chain resolves for.

An empty string is ignored, so a caller threading through an unset flag does not accidentally clear a region set by another option. This module has no default of its own — see the package documentation.

type Source

type Source interface {
	// AWSConfig returns the configuration, resolving it if the source's strategy
	// says to. It is safe for concurrent use.
	AWSConfig(ctx context.Context) (aws.Config, error)
}

Source yields the AWS configuration a service client is built from.

The method is named for what it returns rather than a generic Get, so a call site reads as what it is and two providers' sources are not accidentally interchangeable.

func Ambient

func Ambient(opts ...Option) Source

Ambient returns a source that resolves the ambient AWS configuration chain — profile, SSO session, instance role — the first time it is asked, and shares that result.

It returns immediately and performs no I/O; resolution happens off the construction path, at most once concurrently, and a failure is retried rather than cached.

Example

ExampleAmbient shows the usual wiring. It has no Output because resolving the ambient chain needs real credentials; it is here to be read and to be kept compiling.

The source is built at startup and resolves nothing until something asks. The first AWSConfig call runs one bounded attempt, every later call reuses it, and a failure is retried rather than cached. Handing one source to several adapters is what collapses their credential-chain resolutions into one.

package main

import (
	"context"

	"gitlab.com/phpboyscout/go/awsclient"
)

func main() {
	src := awsclient.Ambient(awsclient.WithRegion("eu-west-2"))

	cfg, err := src.AWSConfig(context.Background())
	if err != nil {
		return
	}

	// Building the service clients from here is pure — no I/O, no error:
	//
	//	ssmClient := ssm.NewFromConfig(cfg)
	//	s3Client  := s3.NewFromConfig(cfg)
	_ = cfg
}

func FromConfig

func FromConfig(cfg aws.Config, opts ...Option) (Source, error)

FromConfig returns a source over a configuration the caller already resolved — a specific profile, an assumed role, a client pointed at LocalStack.

The config is used as-is and nothing is ever loaded, so this rung has no build policy to apply. The region is checked now rather than left to fail per call.

aws.Config is a struct, so there is no nil or typed-nil case to guard here as there is for the providers whose injected value is an interface.

Example

ExampleFromConfig shows the injecting rung: the caller resolved the config, so nothing is loaded and the region is validated immediately.

package main

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	"gitlab.com/phpboyscout/go/awsclient"
)

func main() {
	src, err := awsclient.FromConfig(aws.Config{Region: "eu-west-2"})
	if err != nil {
		fmt.Println("construct:", err)

		return
	}

	cfg, err := src.AWSConfig(context.Background())
	if err != nil {
		fmt.Println("resolve:", err)

		return
	}

	fmt.Println(cfg.Region)
}
Output:
eu-west-2
Example (NoRegion)

ExampleFromConfig_noRegion shows the one thing this module refuses to guess. A config naming no region names a key in an account nobody chose, so it fails at construction rather than several calls later.

package main

import (
	"errors"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	"gitlab.com/phpboyscout/go/awsclient"
)

func main() {
	_, err := awsclient.FromConfig(aws.Config{})

	fmt.Println(errors.Is(err, awsclient.ErrNoRegion))
}
Output:
true

func PerCall

func PerCall(opts ...Option) Source

PerCall returns a source that resolves the ambient chain afresh on every call and retains nothing between them.

Use it where holding credentials between operations would be wrong. Concurrent calls are deliberately not collapsed: sharing one resolution between callers who asked not to share one is the thing this rung exists to avoid.

Example

ExamplePerCall shows the posture for a caller that must not hold credentials between operations — a signing backend that resolves inside each mint. No Output, for the same reason as [ExampleAmbient].

package main

import (
	"context"

	"gitlab.com/phpboyscout/go/awsclient"
)

func main() {
	src := awsclient.PerCall(awsclient.WithRegion("eu-west-2"))

	// Each call resolves afresh and retains nothing, so credentials do not
	// outlive the operation that needed them.
	if _, err := src.AWSConfig(context.Background()); err != nil {
		return
	}
}

Jump to

Keyboard shortcuts

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