gcpclient

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: 9 Imported by: 0

README

gcpclient

Resolve the Google Cloud credential a service client is built from, and hand it over as client options — once and shared, or afresh per operation.

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

src := gcpclient.Ambient(gcpclient.WithScopes(secretmanager.DefaultAuthScopes()...))

opts, err := src.GCPClientOptions(ctx)
client, err := secretmanager.NewClient(ctx, opts...)   // yours to build, yours to Close
defer client.Close()

What this yields, and why it is not a client

GCP is the provider where building a service client is not free. secretmanager.NewClient establishes a gRPC connection, returns an error, and must be Closed. It would be tempting to build the client here and hand it over ready-made — and org 0003 originally said so, before its R1 revision.

It does not, for a reason that only shows up with more than one consumer: the config family alone has three GCP adapters taking three different client types*storage.Client, *parametermanager.Client, *secretmanager.Client. One accessor serves one of them; three would drag three GCP service SDKs into this module and inherit that union to every consumer.

So what crosses the boundary is the credential, as option.ClientOption values. Detecting Application Default Credentials is the expensive, failure-prone, shareable part. Constructing the client is the adapter's own job — and the adapter is where the Close obligation lands.

The rungs

Constructor Detects Use when
FromOptions(opts) nothing you have options already: an emulator endpoint, an explicit credentials file
Ambient() once, lazily, shared anything long-lived
PerCall() every call, retaining nothing holding a credential between operations would be wrong

All three return the same Source. Ambient detects at most once concurrently and — unlike sync.OnceValuesnever caches a failure.

Scopes are required and never guessed

Detecting default credentials needs at least one OAuth scope, and there is no safe default. cloud-platform grants everything the principal can do; a narrower scope fails silently until some later call needs more. So Ambient and PerCall require WithScopes and refuse with ErrNoScopes rather than choosing for you. Each adapter knows the scope its service needs — usually <service>.DefaultAuthScopes().

The returned slice is a copy

Every service constructor invites appending to what it is given. GCPClientOptions returns a fresh slice each call, so a caller who appends cannot disturb a shared source's own value. There is a test for exactly that.

Options

Option Effect
WithScopes(s...) Required for the ambient rungs. Accumulates; empty strings dropped.
WithDetectOptions(o) The SDK's own options — self-signed JWT, custom HTTP client. Scopes are applied over it.
WithBuildTimeout(d) Bounds one attempt (default 30s), per attempt.
WithLifetimeContext(ctx) Scopes an Ambient source's attempts. Ignored by PerCall.
WithLogger(l) DEBUG diagnostics. Off by default.

Logging

Off unless you pass a logger. Records carry the scopes requested and how long detection took — and nothing read off the credential itself, because those accessors can make a network call, and a log statement that reaches the network is a hidden failure mode in the one place nobody looks for one. No token is fetched, and nothing is logged above DEBUG.

What it costs

Fifteen auth-graph modules, plus clientlifecycle and errors — both dependency-free. depfootprint_test.go asserts the exact set and fails if a GCP service package appears. That check is the rule R1 exists to protect.

Specification

org 0003 (P-2 as revised by R1, P-3, P-12, P-13, P-14), over org 0002. The state machine beneath Ambient is clientlifecycle.

Licence

MIT. See LICENSE.

Documentation

Overview

Package gcpclient resolves the Google Cloud credential a service client is built from, and hands it over as client options — once and shared, or afresh per operation, as the caller chooses.

What this yields, and why it is not a client

GCP is the provider where building a service client is *not* free. secretmanager.NewClient establishes a gRPC connection, returns an error, and must be Closed. It would be tempting to build the client here and hand it over ready-made — and org 0003 originally said so, before its R1 revision.

It does not, for a reason that only shows up with more than one consumer: the config family alone has three GCP adapters taking three different client types (*storage.Client, *parametermanager.Client, *secretmanager.Client). One accessor serves one of them; three would drag three GCP service SDKs into this module and inherit that union to every consumer.

So what crosses the boundary is the credential, expressed as option.ClientOption values. Detecting Application Default Credentials is the expensive, failure-prone, shareable part; constructing the client is the adapter's own job, and the adapter is where the Close obligation lands.

opts, err := src.GCPClientOptions(ctx)
client, err := secretmanager.NewClient(ctx, opts...)  // adapter owns this, and Closes it

Choosing a rung

gcpclient.FromOptions(opts)  // you have client options already; used as-is
gcpclient.Ambient()          // Application Default Credentials, detected once and shared
gcpclient.PerCall()          // the same, detected afresh every time

Ambient resolves lazily on first use, at most once concurrently, and — unlike sync.OnceValues — never caches a failure.

Scopes are required and never guessed

Detecting default credentials needs at least one OAuth scope, and there is no safe default: cloud-platform grants everything the caller can do, and a narrower scope silently fails only when a call needs more. So Ambient and PerCall require WithScopes, and refuse with ErrNoScopes rather than choosing on the caller's behalf. Each adapter knows the scope its service needs; this module does not.

Specified by org 0003 (P-2 as revised by R1, P-3, P-12, P-13, P-14).

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoScopes reports an ambient rung constructed without [WithScopes].
	//
	// There is no safe default scope, and guessing one either over-grants — a
	// cloud-platform token can do everything its principal can — or under-grants
	// in a way that only surfaces when some later call needs more.
	ErrNoScopes = errors.NewSentinel("gcpclient.no_scopes",
		"no OAuth scopes configured; pass WithScopes with the scope the service needs")

	// ErrNoOptions reports an empty or nil slice supplied to [FromOptions].
	ErrNoOptions = errors.NewSentinel("gcpclient.no_options",
		"no client options supplied; pass at least one, or use Ambient to detect credentials")

	// ErrNoCredentials reports a detector that returned neither credentials nor
	// an error — a pair that would otherwise reach a service constructor and fail
	// there instead of here.
	ErrNoCredentials = errors.NewSentinel("gcpclient.no_credentials",
		"credential detection returned nothing and no error")
)

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 detection attempt. Per attempt, not a total budget: a failure caches nothing, so a later call gets a fresh allowance.

func WithDetectOptions

func WithDetectOptions(opts *credentials.DetectOptions) Option

WithDetectOptions passes the SDK's own options through to credentials.DetectDefault — an explicit credentials file, a self-signed JWT, a custom HTTP client.

It is the escape hatch for everything WithScopes does not cover. Scopes set by WithScopes are applied over whatever this carries, so the two compose in the order they read.

func WithLifetimeContext

func WithLifetimeContext(ctx context.Context) Option

WithLifetimeContext scopes an Ambient source's detection attempts to the life of whatever owns it. PerCall ignores it, being already scoped to its caller.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger enables DEBUG diagnostics. nil disables them, which is the default.

Records carry only non-secret identifiers — the scopes requested and how long detection took. Nothing is read off the credential itself, because those accessors can make a network call and a log statement that reaches the network is a hidden failure mode. No token is requested here, and nothing is logged above DEBUG.

func WithScopes

func WithScopes(scopes ...string) Option

WithScopes sets the OAuth scopes credential detection requests. Required for Ambient and PerCall; scopes accumulate across calls, and empty strings are dropped so an unset flag cannot introduce a blank scope.

type Source

type Source interface {
	// GCPClientOptions returns the options, detecting credentials if the source's
	// strategy says to. It is safe for concurrent use.
	//
	// The returned slice is a copy: a caller appending to it — which every service
	// constructor invites — cannot disturb a shared source's own value.
	GCPClientOptions(ctx context.Context) ([]option.ClientOption, error)
}

Source yields the client options a GCP 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 detects Application Default Credentials the first time it is asked, and shares the result.

It returns immediately and performs no I/O.

Example

ExampleAmbient shows the usual wiring. No Output because detection needs real Application Default Credentials; it is here to be read and kept compiling.

Note where the client is built: this module hands over the credential, and the adapter constructs — and Closes — its own service client.

package main

import (
	"context"

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

func main() {
	src := gcpclient.Ambient(
		gcpclient.WithScopes("https://www.googleapis.com/auth/cloud-platform"),
	)

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

	//	client, err := secretmanager.NewClient(ctx, opts...)
	//	defer client.Close()
	_ = opts
}
Example (NoScopes)

ExampleAmbient_noScopes shows the one thing this module refuses to guess. There is no safe default scope: cloud-platform grants everything the principal can do, and a narrower one fails only when some later call needs more.

package main

import (
	"context"
	"errors"
	"fmt"

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

func main() {
	_, err := gcpclient.Ambient().GCPClientOptions(context.Background())

	fmt.Println(errors.Is(err, gcpclient.ErrNoScopes))
}
Output:
true

func FromOptions

func FromOptions(clientOpts []option.ClientOption, opts ...Option) (Source, error)

FromOptions returns a source over client options the caller already holds — an explicit credentials file, an emulator endpoint, a pre-detected credential.

The options are used as-is and nothing is detected, so this rung has no build policy to apply.

Example

ExampleFromOptions shows the injecting rung — an emulator endpoint, say.

package main

import (
	"context"
	"fmt"

	"gitlab.com/phpboyscout/go/gcpclient"
	"google.golang.org/api/option"
)

func main() {
	src, err := gcpclient.FromOptions([]option.ClientOption{
		option.WithEndpoint("http://localhost:8085"),
	})
	if err != nil {
		fmt.Println("construct:", err)

		return
	}

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

		return
	}

	fmt.Println(len(opts))
}
Output:
1

func PerCall

func PerCall(opts ...Option) Source

PerCall returns a source that detects afresh on every call and retains nothing.

Concurrent calls are deliberately not collapsed: sharing one detection between callers who asked not to share one is the thing this rung exists to avoid.

Jump to

Keyboard shortcuts

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