Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cliext/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cliext

import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"strings"
Expand Down Expand Up @@ -34,6 +36,19 @@ type ClientOptionsBuilder struct {
// configured. Callers can use it to decode payloads outside the gRPC
// interceptor chain (e.g. payloads nested inside opaque proto bytes).
PayloadCodec converter.PayloadCodec

// ResolvedAddress is populated by Build with the final server address,
// after profile resolution and namespace templating.
ResolvedAddress string
// ResolvedAddressSource is populated by Build with where the final address
// came from: "flag", "profile", or "default".
ResolvedAddressSource string
// ResolvedProfileName is populated by Build with the config profile name
// used, if any.
ResolvedProfileName string
// HasAPIKey is populated by Build and reports whether API-key credentials
// are configured (via flag, profile, or OAuth).
HasAPIKey bool
}

type oauthCredentials struct {
Expand Down Expand Up @@ -108,8 +123,12 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error
// override the profile version unless it was _explicitly_ set.
if cfg.FlagSet != nil && cfg.FlagSet.Changed("address") {
profile.Address = cfg.Address
b.ResolvedAddressSource = "flag"
} else if profile.Address == "" {
profile.Address = cfg.Address
b.ResolvedAddressSource = "default"
} else {
b.ResolvedAddressSource = "profile"
}
var namespaceExplicitlySet bool
if cfg.FlagSet != nil && cfg.FlagSet.Changed("namespace") {
Expand All @@ -126,6 +145,7 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error
if addressHasNamespaceTemplate {
profile.Address = strings.ReplaceAll(profile.Address, addressNamespaceTemplate, profile.Namespace)
}
b.ResolvedAddress = profile.Address

// Set API key on profile if provided
if cfg.ApiKey != "" {
Expand Down Expand Up @@ -204,9 +224,18 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error
profile.Codec.Auth = cfg.CodecAuth
}

b.ResolvedProfileName = common.Profile
b.HasAPIKey = profile.APIKey != ""

// Convert profile to client options.
clientOpts, err := profile.ToClientOptions(envconfig.ToClientOptionsRequest{})
if err != nil {
// File-path errors (e.g. an unreadable TLS cert) are common enough to
// deserve naming the offending file.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return client.Options{}, fmt.Errorf("failed to build client options: cannot read file %q: %w", pathErr.Path, err)
}
return client.Options{}, fmt.Errorf("failed to build client options: %w", err)
}

Expand Down Expand Up @@ -249,6 +278,7 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error
profileName: result.ProfileName,
}
clientOpts.Credentials = client.NewAPIKeyDynamicCredentials(creds.getToken)
b.HasAPIKey = true
}
}

Expand Down
63 changes: 62 additions & 1 deletion internal/temporalcli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package temporalcli

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/user"

Expand Down Expand Up @@ -56,6 +58,17 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.
}
clientOpts, err := builder.Build(cctx)
if err != nil {
// An unreadable TLS/credential file is a connection-setup problem
// worth a suggestion, even though no dial happened.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
diag := &connectDiagnosis{
Address: builder.ResolvedAddress,
Cause: causeCertFileUnreadable,
Detail: pathErr.Path,
}
return nil, nil, newConnectError(diag, connectMetaFromBuilder(cctx, builder), err)
}
return nil, nil, err
}

Expand Down Expand Up @@ -87,7 +100,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.

cl, err := client.DialContext(dialCtx, clientOpts)
if err != nil {
return nil, nil, err
return nil, nil, dialConnectError(cctx, dialCtx, builder, clientOpts, err)
}

// Since this namespace value is used by many commands after this call,
Expand All @@ -97,6 +110,54 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.
return cl, builder.PayloadCodec, nil
}

// dialConnectError enriches a client.DialContext failure with a staged
// connection diagnosis and a suggested fix. See connectdiag.go.
func dialConnectError(
cctx *CommandContext,
dialCtx context.Context,
builder *cliext.ClientOptionsBuilder,
clientOpts client.Options,
origErr error,
) error {
// If a CLI-side timeout fired, surface its descriptive cause, which is
// otherwise lost ("context deadline exceeded" wins over "command timed
// out after 5s").
if dialCtx.Err() != nil {
if cause := context.Cause(dialCtx); cause != nil && !errors.Is(cause, dialCtx.Err()) {
origErr = fmt.Errorf("%w (%v)", origErr, cause)
}
}
// Never probe after an interrupt or when the whole command timed out.
if cctx.Err() != nil {
return origErr
}
if v, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS"); v != "" {
return fmt.Errorf("failed connecting to Temporal server at %v: %w", clientOpts.HostPort, origErr)
}
// The probe has its own internal budget, but never let it exceed the
// user's explicit connect timeout.
probeCtx := context.Context(cctx)
if timeout := cctx.RootCommand.CommonOptions.ClientConnectTimeout.Duration(); timeout > 0 {
var cancel context.CancelFunc
probeCtx, cancel = context.WithTimeout(cctx, timeout)
defer cancel()
}
diag := diagnoseConnection(probeCtx, clientOpts.HostPort, clientOpts.ConnectionOptions.TLS, origErr)
meta := connectMetaFromBuilder(cctx, builder)
meta.TLSConfigured = clientOpts.ConnectionOptions.TLS != nil
return newConnectError(diag, meta, origErr)
}

func connectMetaFromBuilder(cctx *CommandContext, builder *cliext.ClientOptionsBuilder) connectMeta {
return connectMeta{
Args: cctx.Options.Args,
Address: builder.ResolvedAddress,
AddressSource: builder.ResolvedAddressSource,
ProfileName: builder.ResolvedProfileName,
HasAPIKey: builder.HasAPIKey,
}
}

func fixedHeaderOverrideInterceptor(
ctx context.Context,
method string, req, reply any,
Expand Down
Loading
Loading