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
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Uses normal `go test`, e.g.:

go test ./...

The root command does not enter the nested `cliext` module. Run `(cd cliext && go test ./...)` separately.

The current transition branch has a recorded standalone `cliext` compile mismatch. Until that mismatch is fixed, the nested command must match the baseline exception. Do not report it as green.

See other tests for how to leverage things like the command harness and dev server suite.

Example to run a single test case:
Expand All @@ -20,11 +24,17 @@ Example to run a single test case:

## Adding/updating commands

Follow [Structured Error Handling](docs/structured-error-handling.md) when a command adds or changes terminal failure behavior.

First, update [commands.yaml](internal/temporalcli/commands.yaml) following the rules in that file. Then to regenerate the
[commands.gen.go](internal/temporalcli/commands.gen.go) file from code, run:

go run ./cmd/gen-commands -input internal/temporalcli/commands.yaml -pkg temporalcli -context "*CommandContext" > internal/temporalcli/commands.gen.go

Generated commands currently inherit the existing `cctx.Options.Fail(err)` adapter. Regeneration may retain those calls until Phase A of [Structured Error Handling](docs/structured-error-handling.md#transition-gates) changes the generator.

Do not add handwritten or direct `Fail` calls. Do not create a new generator pattern that emits them or copy the adapter into new code. Phase A removes generated `Fail` calls entirely.

This will expect every non-parent command to have a `run` method, so for new commands developers will have to implement
`run` on the new command in a separate file before it will compile.

Expand Down
4 changes: 3 additions & 1 deletion cmd/temporal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"os"

"github.com/temporalio/cli/internal/temporalcli"

Expand All @@ -15,5 +16,6 @@ import (
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
temporalcli.Execute(ctx, temporalcli.CommandOptions{})
result := temporalcli.Execute(ctx, temporalcli.CommandOptions{})
os.Exit(result.ExitStatus)
}
304 changes: 304 additions & 0 deletions docs/structured-error-handling.md

Large diffs are not rendered by default.

129 changes: 128 additions & 1 deletion internal/temporalcli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package temporalcli

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

"github.com/temporalio/cli/cliext"
"go.temporal.io/api/common/v1"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/contrib/envconfig"
"go.temporal.io/sdk/converter"
"go.temporal.io/sdk/workflow"
"google.golang.org/grpc"
Expand Down Expand Up @@ -46,6 +50,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.
}
c.Identity = "temporal-cli:" + username + "@" + hostname
}
captureEffectiveConnectionSecrets(cctx, c)

// Build client options using cliext
builder := &cliext.ClientOptionsBuilder{
Expand All @@ -56,6 +61,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: c.Address,
Cause: causeCertFileUnreadable,
Detail: pathErr.Path,
}
return nil, nil, newConnectError(diag, connectMetaFromOptions(cctx, c, client.Options{HostPort: c.Address}), err)
}
return nil, nil, err
}

Expand Down Expand Up @@ -87,7 +103,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, c, clientOpts, err)
}

// Since this namespace value is used by many commands after this call,
Expand All @@ -97,6 +113,117 @@ 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,
configured *cliext.ClientOptions,
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)
}
// Diagnosis uses the remaining command context and applies its own 3s cap.
// ClientConnectTimeout governs only the original dial.
diag := diagnoseConnection(cctx, clientOpts.HostPort, clientOpts.ConnectionOptions.TLS, origErr)
meta := connectMetaFromOptions(cctx, configured, clientOpts)
meta.TLSConfigured = clientOpts.ConnectionOptions.TLS != nil
return newConnectError(diag, meta, origErr)
}

func connectMetaFromOptions(cctx *CommandContext, configured *cliext.ClientOptions, resolved client.Options) connectMeta {
meta := connectMeta{Address: resolved.HostPort, HasAPIKey: cctx.hasAPIKey, HasOAuth: cctx.hasOAuth}
if configured != nil && configured.FlagSet != nil && configured.FlagSet.Changed("address") {
meta.AddressSource = "flag"
} else if configured != nil && resolved.HostPort != configured.Address {
meta.AddressSource = "profile"
} else {
meta.AddressSource = "default"
}
if cctx.RootCommand != nil {
meta.ProfileName = cctx.RootCommand.CommonOptions.Profile
}
return meta
}

func captureEffectiveConnectionSecrets(cctx *CommandContext, configured *cliext.ClientOptions) {
if configured != nil {
cctx.registerSecrets(
configured.ApiKey,
configured.CodecAuth,
configured.TlsCertData,
configured.TlsKeyData,
configured.TlsCaData,
)
for _, value := range append(append([]string(nil), configured.CodecHeader...), configured.GrpcMeta...) {
cctx.registerSecrets(value)
if _, secret, ok := strings.Cut(value, "="); ok {
cctx.registerSecrets(secret)
}
}
if configured.ApiKey != "" {
cctx.hasAPIKey = true
}
}
if cctx.RootCommand == nil {
return
}
common := cctx.RootCommand.CommonOptions
if !common.DisableConfigFile || !common.DisableConfigEnv {
profile, err := envconfig.LoadClientConfigProfile(envconfig.LoadClientConfigProfileOptions{
ConfigFilePath: common.ConfigFile, ConfigFileProfile: common.Profile,
DisableFile: common.DisableConfigFile, DisableEnv: common.DisableConfigEnv,
EnvLookup: cctx.Options.EnvLookup,
})
if err == nil {
cctx.registerSecrets(profile.APIKey)
cctx.hasAPIKey = cctx.hasAPIKey || profile.APIKey != ""
if profile.TLS != nil {
cctx.registerSecrets(string(profile.TLS.ClientCertData), string(profile.TLS.ClientKeyData), string(profile.TLS.ServerCACertData))
}
if profile.Codec != nil {
cctx.registerSecrets(profile.Codec.Auth)
}
for _, value := range profile.GRPCMeta {
cctx.registerSecrets(value)
}
}
}
// Match ClientOptionsBuilder precedence: an explicit --api-key suppresses
// OAuth loading, while a profile API key can still be replaced by a usable
// OAuth token from the same profile.
if common.DisableConfigFile || configured != nil && configured.ApiKey != "" {
return
}
oauth, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{
ConfigFilePath: common.ConfigFile, ProfileName: common.Profile, EnvLookup: cctx.Options.EnvLookup,
})
if err != nil || oauth.OAuth == nil {
return
}
if oauth.OAuth.ClientConfig != nil {
cctx.registerSecrets(oauth.OAuth.ClientConfig.ClientSecret)
}
if oauth.OAuth.Token != nil {
cctx.registerSecrets(oauth.OAuth.Token.AccessToken, oauth.OAuth.Token.RefreshToken)
cctx.hasOAuth = oauth.OAuth.Token.AccessToken != ""
}
}

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