From 0e5880980737f8ff861ea59631e3243dbc584edd Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Tue, 18 Feb 2025 16:47:32 -0600 Subject: [PATCH 1/8] Support environment config Fixes #751 --- go.mod | 6 + go.sum | 4 +- temporalcli/client.go | 227 ++++++++----- temporalcli/commands.config.go | 325 ++++++++++++++++++ temporalcli/commands.config_test.go | 374 +++++++++++++++++++++ temporalcli/commands.env.go | 72 +++- temporalcli/commands.env_test.go | 12 +- temporalcli/commands.gen.go | 170 ++++++++-- temporalcli/commands.go | 121 +++---- temporalcli/commands.server.go | 6 +- temporalcli/commands.workflow_exec_test.go | 14 +- temporalcli/commands_test.go | 22 +- temporalcli/commandsgen/code.go | 10 + temporalcli/commandsgen/commands.yml | 206 ++++++++++-- temporalcli/commandsgen/parse.go | 3 + temporalcli/internal/printer/printer.go | 4 + 16 files changed, 1331 insertions(+), 245 deletions(-) create mode 100644 temporalcli/commands.config.go create mode 100644 temporalcli/commands.config_test.go diff --git a/go.mod b/go.mod index e28b3ea44..ec06348a6 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,12 @@ module github.com/temporalio/cli go 1.23.2 +replace go.temporal.io/sdk/contrib/envconfig v0.0.0-unpublished => ../temporal-sdk-go/contrib/envconfig + +replace go.temporal.io/sdk v1.32.1 => ../temporal-sdk-go + require ( + github.com/BurntSushi/toml v1.4.0 github.com/alitto/pond v1.9.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dustin/go-humanize v1.0.1 @@ -17,6 +22,7 @@ require ( github.com/temporalio/ui-server/v2 v2.32.0 go.temporal.io/api v1.44.1 go.temporal.io/sdk v1.32.1 + go.temporal.io/sdk/contrib/envconfig v0.0.0-unpublished go.temporal.io/server v1.27.0-128.0 google.golang.org/grpc v1.70.0 google.golang.org/protobuf v1.36.4 diff --git a/go.sum b/go.sum index d0b6f8134..dcfc1f16f 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 h1:f2Qw/Ehhimh5uO1fayV0QIW7DShEQqhtUfhYc+cBPlw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0/go.mod h1:2bIszWvQRlJVmJLiuLhukLImRjKPcYdzzsx6darK02A= @@ -369,8 +371,6 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.temporal.io/api v1.44.1 h1:sb5Hq08AB0WtYvfLJMiWmHzxjqs2b+6Jmzg4c8IOeng= go.temporal.io/api v1.44.1/go.mod h1:1WwYUMo6lao8yl0371xWUm13paHExN5ATYT/B7QtFis= -go.temporal.io/sdk v1.32.1 h1:slA8prhdFr4lxpsTcRusWVitD/cGjELfKUh0mBj73SU= -go.temporal.io/sdk v1.32.1/go.mod h1:8U8H7rF9u4Hyb4Ry9yiEls5716DHPNvVITPNkgWUwE8= go.temporal.io/server v1.27.0-128.0 h1:rGQGzr8lOCDhf5Z6jnbv5LRSZa3r1IyvPf1d2ywrDhY= go.temporal.io/server v1.27.0-128.0/go.mod h1:YgN/yuBArvm7q5VEk2SXY+cGTTvDbt5AyH34DvEd3so= go.temporal.io/version v0.3.0 h1:dMrei9l9NyHt8nG6EB8vAwDLLTwx2SvRyucCSumAiig= diff --git a/temporalcli/client.go b/temporalcli/client.go index d243f668f..6ad689808 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -2,8 +2,6 @@ package temporalcli import ( "context" - "crypto/tls" - "crypto/x509" "fmt" "net/http" "os" @@ -12,47 +10,167 @@ import ( "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/log" - "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) { - clientOptions := client.Options{ - HostPort: c.Address, - Namespace: c.Namespace, - Logger: log.NewStructuredLogger(cctx.Logger), - Identity: clientIdentity(), - // We do not put codec on data converter here, it is applied via - // interceptor. Same for failure conversion. - // XXX: If this is altered to be more dynamic, have to also update - // everywhere DataConverterWithRawValue is used. - DataConverter: DataConverterWithRawValue, - } - - // API key - if c.ApiKey != "" { - clientOptions.Credentials = client.NewAPIKeyStaticCredentials(c.ApiKey) + if cctx.RootCommand == nil { + return nil, fmt.Errorf("root command unexpectedly missing when dialing client") } - // Headers + // Load a client config profile + var clientProfile envconfig.ClientConfigProfile + if !cctx.RootCommand.DisableConfigFile || !cctx.RootCommand.DisableConfigEnv { + var err error + clientProfile, err = envconfig.LoadClientConfigProfile(envconfig.LoadClientConfigProfileOptions{ + ConfigFilePath: cctx.RootCommand.ConfigFile, + ConfigFileProfile: cctx.RootCommand.Profile, + DisableFile: cctx.RootCommand.DisableConfigFile, + DisableEnv: cctx.RootCommand.DisableConfigEnv, + EnvLookup: cctx.Options.EnvLookup, + }) + if err != nil { + return nil, fmt.Errorf("failed loading client config: %w", err) + } + } + + // To support legacy TLS environment variables, if they are present, we will + // have them force-override anything loaded from existing file or env + if !cctx.RootCommand.DisableConfigEnv { + oldEnvTLSCert, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_TLS_CERT") + oldEnvTLSCertData, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_TLS_CERT_DATA") + oldEnvTLSKey, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_TLS_KEY") + oldEnvTLSKeyData, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_TLS_KEY_DATA") + oldEnvTLSCA, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_TLS_CA") + oldEnvTLSCAData, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_TLS_CA_DATA") + if oldEnvTLSCert != "" || oldEnvTLSCertData != "" || + oldEnvTLSKey != "" || oldEnvTLSKeyData != "" || + oldEnvTLSCA != "" || oldEnvTLSCAData != "" { + if clientProfile.TLS == nil { + clientProfile.TLS = &envconfig.ClientConfigTLS{} + } + if oldEnvTLSCert != "" { + clientProfile.TLS.ClientCertPath = oldEnvTLSCert + } + if oldEnvTLSCertData != "" { + clientProfile.TLS.ClientCertData = []byte(oldEnvTLSCertData) + } + if oldEnvTLSKey != "" { + clientProfile.TLS.ClientKeyPath = oldEnvTLSKey + } + if oldEnvTLSKeyData != "" { + clientProfile.TLS.ClientKeyData = []byte(oldEnvTLSKeyData) + } + if oldEnvTLSCA != "" { + clientProfile.TLS.ServerCACertPath = oldEnvTLSCA + } + if oldEnvTLSCAData != "" { + clientProfile.TLS.ServerCACertData = []byte(oldEnvTLSCAData) + } + } + } + + // Override some values in client config profile that come from CLI args + if c.Address != "" { + clientProfile.Address = c.Address + } + if c.Namespace != "" { + clientProfile.Namespace = c.Namespace + } + if c.ApiKey != "" { + clientProfile.APIKey = c.ApiKey + } if len(c.GrpcMeta) > 0 { - headers := make(stringMapHeadersProvider, len(c.GrpcMeta)) + // We append meta, not override + if len(clientProfile.GRPCMeta) == 0 { + clientProfile.GRPCMeta = make(map[string]string, len(c.GrpcMeta)) + } for _, kv := range c.GrpcMeta { pieces := strings.SplitN(kv, "=", 2) if len(pieces) != 2 { return nil, fmt.Errorf("gRPC meta of %q does not have '='", kv) } - headers[pieces[0]] = pieces[1] + clientProfile.GRPCMeta[pieces[0]] = pieces[1] } - clientOptions.HeadersProvider = headers } - // Remote codec + // If any of these values are present, set TLS if not set, and set values. + // NOTE: This means that tls=false does not explicitly disable TLS when set + // via envconfig. + if c.Tls || + c.TlsCertPath != "" || c.TlsKeyPath != "" || c.TlsCaPath != "" || + c.TlsCertData != "" || c.TlsKeyData != "" || c.TlsCaData != "" { + if clientProfile.TLS == nil { + clientProfile.TLS = &envconfig.ClientConfigTLS{} + } + if c.TlsCertPath != "" { + clientProfile.TLS.ClientCertPath = c.TlsCertPath + } + if c.TlsCertData != "" { + clientProfile.TLS.ClientCertData = []byte(c.TlsCertData) + } + if c.TlsKeyPath != "" { + clientProfile.TLS.ClientKeyPath = c.TlsKeyPath + } + if c.TlsKeyData != "" { + clientProfile.TLS.ClientKeyData = []byte(c.TlsKeyData) + } + if c.TlsCaPath != "" { + clientProfile.TLS.ServerCACertPath = c.TlsCaPath + } + if c.TlsCaData != "" { + clientProfile.TLS.ServerCACertData = []byte(c.TlsCaData) + } + if c.TlsServerName != "" { + clientProfile.TLS.ServerName = c.TlsServerName + } + if c.TlsDisableHostVerification { + clientProfile.TLS.DisableHostVerification = c.TlsDisableHostVerification + } + } + // In the past, the presence of API key CLI arg did not imply TLS like it + // does with envconfig. Therefore if there is a user-provided API key and + // TLS is not present, explicitly disable it so API key presence doesn't + // enable it in ToClientOptions below. + // TODO(cretz): Or do we want to break compatibility to have TLS defaulted + // for all API keys? + if c.ApiKey != "" && clientProfile.TLS == nil { + clientProfile.TLS = &envconfig.ClientConfigTLS{Disabled: true} + } + + // If codec endpoint is set, create codec setting regardless. But if auth is + // set, it only overrides if codec is present. if c.CodecEndpoint != "" { - interceptor, err := payloadCodecInterceptor(c.Namespace, c.CodecEndpoint, c.CodecAuth) + if clientProfile.Codec == nil { + clientProfile.Codec = &envconfig.ClientConfigCodec{} + } + clientProfile.Codec.Endpoint = c.CodecEndpoint + } + if c.CodecAuth != "" && clientProfile.Codec != nil { + clientProfile.Codec.Auth = c.CodecAuth + } + + // Now load client options from the profile + clientOptions, err := clientProfile.ToClientOptions(envconfig.ToClientOptionsOptions{}) + if err != nil { + return nil, fmt.Errorf("failed creating client options: %w", err) + } + clientOptions.Logger = log.NewStructuredLogger(cctx.Logger) + clientOptions.Identity = clientIdentity() + // We do not put codec on data converter here, it is applied via + // interceptor. Same for failure conversion. + // XXX: If this is altered to be more dynamic, have to also update + // everywhere DataConverterWithRawValue is used. + clientOptions.DataConverter = DataConverterWithRawValue + + // Remote codec + if clientProfile.Codec != nil && clientProfile.Codec.Endpoint != "" { + interceptor, err := payloadCodecInterceptor( + clientProfile.Namespace, clientProfile.Codec.Endpoint, clientProfile.Codec.Auth) if err != nil { return nil, fmt.Errorf("failed creating payload codec interceptor: %w", err) } @@ -68,64 +186,9 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) clientOptions.ConnectionOptions.DialOptions = append( clientOptions.ConnectionOptions.DialOptions, cctx.Options.AdditionalClientGRPCDialOptions...) - // TLS - var err error - if clientOptions.ConnectionOptions.TLS, err = c.tlsConfig(); err != nil { - return nil, err - } - return client.Dial(clientOptions) } -func (c *ClientOptions) tlsConfig() (*tls.Config, error) { - // We need TLS if any of these TLS options are set - if !c.Tls && - c.TlsCaPath == "" && c.TlsCertPath == "" && c.TlsKeyPath == "" && - c.TlsCaData == "" && c.TlsCertData == "" && c.TlsKeyData == "" { - return nil, nil - } - - conf := &tls.Config{ - ServerName: c.TlsServerName, - InsecureSkipVerify: c.TlsDisableHostVerification, - } - - if c.TlsCertPath != "" { - if c.TlsCertData != "" { - return nil, fmt.Errorf("cannot specify both --tls-cert-path and --tls-cert-data") - } - clientCert, err := tls.LoadX509KeyPair(c.TlsCertPath, c.TlsKeyPath) - if err != nil { - return nil, fmt.Errorf("failed loading client cert key pair: %w", err) - } - conf.Certificates = append(conf.Certificates, clientCert) - } else if c.TlsCertData != "" { - clientCert, err := tls.X509KeyPair([]byte(c.TlsCertData), []byte(c.TlsKeyData)) - if err != nil { - return nil, fmt.Errorf("failed loading client cert key pair: %w", err) - } - conf.Certificates = append(conf.Certificates, clientCert) - } - - if c.TlsCaPath != "" { - if c.TlsCaData != "" { - return nil, fmt.Errorf("cannot specify both --tls-ca-path and --tls-ca-data") - } - conf.RootCAs = x509.NewCertPool() - if b, err := os.ReadFile(c.TlsCaPath); err != nil { - return nil, fmt.Errorf("failed reading CA cert from %v: %w", c.TlsCaPath, err) - } else if !conf.RootCAs.AppendCertsFromPEM(b) { - return nil, fmt.Errorf("invalid CA cert from %v", c.TlsCaPath) - } - } else if c.TlsCaData != "" { - conf.RootCAs = x509.NewCertPool() - if !conf.RootCAs.AppendCertsFromPEM([]byte(c.TlsCaData)) { - return nil, fmt.Errorf("invalid CA cert data") - } - } - return conf, nil -} - func fixedHeaderOverrideInterceptor( ctx context.Context, method string, req, reply any, @@ -179,12 +242,6 @@ func clientIdentity() string { return "temporal-cli:" + username + "@" + hostname } -type stringMapHeadersProvider map[string]string - -func (s stringMapHeadersProvider) GetHeaders(context.Context) (map[string]string, error) { - return s, nil -} - var DataConverterWithRawValue = converter.NewCompositeDataConverter( rawValuePayloadConverter{}, converter.NewNilPayloadConverter(), diff --git a/temporalcli/commands.config.go b/temporalcli/commands.config.go new file mode 100644 index 000000000..7ce1e4b78 --- /dev/null +++ b/temporalcli/commands.config.go @@ -0,0 +1,325 @@ +package temporalcli + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/BurntSushi/toml" + "github.com/temporalio/cli/temporalcli/internal/printer" + "go.temporal.io/sdk/contrib/envconfig" +) + +func (c *TemporalConfigDeleteCommand) run(cctx *CommandContext, _ []string) error { + // Load config + profileName := envConfigProfileName(cctx) + conf, confProfile, err := loadEnvConfigProfile(cctx, profileName, true) + if err != nil { + return err + } + // If it's a specific prop, unset it, otherwise just remove the profile + if c.Prop == "" { + // To make extra sure they meant to do this, we require the profile name + // as an explicit CLI arg. This prevents accidentally deleting the + // "default" profile. + if cctx.RootCommand.Profile == "" { + return fmt.Errorf("to delete an entire profile, --profile must be provided explicitly") + } + delete(conf.Profiles, profileName) + } else if strings.HasPrefix(c.Prop, "grpc_meta.") { + key := strings.TrimPrefix(c.Prop, "grpc_meta.") + if _, ok := confProfile.GRPCMeta[key]; !ok { + return fmt.Errorf("gRPC meta key %q not found", key) + } + delete(confProfile.GRPCMeta, key) + } else { + reflectVal, err := reflectEnvConfigProp(confProfile, c.Prop, true) + if err != nil { + return err + } + reflectVal.SetZero() + } + + // Save + return writeEnvConfigFile(cctx, conf) +} + +func (c *TemporalConfigGetCommand) run(cctx *CommandContext, _ []string) error { + // Load config profile + profileName := envConfigProfileName(cctx) + conf, confProfile, err := loadEnvConfigProfile(cctx, profileName, true) + if err != nil { + return err + } + type prop struct { + Property string `json:"property"` + Value any `json:"value"` + } + // If there is a specific key requested, show it, otherwise show all + if c.Prop != "" { + // We do not support asking for structures with children at this time, + // but "tls" is a special case because it's also a bool. + if c.Prop == "codec" || c.Prop == "grpc_meta" { + return fmt.Errorf("must provide exact property, not parent property") + } + var reflectVal reflect.Value + // gRPC meta is special + if strings.HasPrefix(c.Prop, "grpc_meta.") { + v, ok := confProfile.GRPCMeta[strings.TrimPrefix(c.Prop, "grpc_meta.")] + if !ok { + return fmt.Errorf("unknown property %q", c.Prop) + } + reflectVal = reflect.ValueOf(v) + } else { + // Single value goes into property-value structure + reflectVal, err = reflectEnvConfigProp(confProfile, c.Prop, false) + if err != nil { + return err + } + // Pointers become true/false + if reflectVal.Kind() == reflect.Pointer { + reflectVal = reflect.ValueOf(!reflectVal.IsNil()) + } + } + return cctx.Printer.PrintStructured( + prop{Property: c.Prop, Value: reflectVal.Interface()}, + printer.StructuredOptions{Table: &printer.TableOptions{}}, + ) + } else if cctx.JSONOutput { + // If it is JSON and not prop specific, we want to dump the TOML + // structure in JSON form + var tomlConf struct { + Profiles map[string]any `toml:"profile"` + } + if b, err := conf.ToTOML(envconfig.ClientConfigToTOMLOptions{}); err != nil { + return fmt.Errorf("failed converting to TOML: %w", err) + } else if err := toml.Unmarshal(b, &tomlConf); err != nil { + return fmt.Errorf("failed converting from TOML: %w", err) + } + return cctx.Printer.PrintStructured(tomlConf.Profiles[profileName], printer.StructuredOptions{}) + } else { + // Get every property individually as a property-value pair except zero + // vals + var props []prop + for k := range envConfigPropsToFieldNames { + // TLS is a special case + if k == "tls" { + if confProfile.TLS != nil { + props = append(props, prop{Property: "tls", Value: true}) + } + continue + } + if val, err := reflectEnvConfigProp(confProfile, k, false); err != nil { + return err + } else if !val.IsZero() { + props = append(props, prop{Property: k, Value: val.Interface()}) + } + } + + // Add "grpc_meta" + for k, v := range confProfile.GRPCMeta { + props = append(props, prop{Property: "grpc_meta." + k, Value: v}) + } + + // Sort and display + sort.Slice(props, func(i, j int) bool { return props[i].Property < props[j].Property }) + return cctx.Printer.PrintStructured(props, printer.StructuredOptions{Table: &printer.TableOptions{}}) + } +} + +func (c *TemporalConfigListCommand) run(cctx *CommandContext, _ []string) error { + clientConfig, err := envconfig.LoadClientConfig(envconfig.LoadClientConfigOptions{ + ConfigFilePath: cctx.RootCommand.ConfigFile, + EnvLookup: cctx.Options.EnvLookup, + }) + if err != nil { + return err + } + type profile struct { + Name string `json:"name"` + } + profiles := make([]profile, 0, len(clientConfig.Profiles)) + for k := range clientConfig.Profiles { + profiles = append(profiles, profile{Name: k}) + } + sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) + return cctx.Printer.PrintStructured(profiles, printer.StructuredOptions{Table: &printer.TableOptions{}}) +} + +func (c *TemporalConfigSetCommand) run(cctx *CommandContext, _ []string) error { + // Load config + conf, confProfile, err := loadEnvConfigProfile(cctx, envConfigProfileName(cctx), false) + if err != nil { + return err + } + // As a special case, "grpc_meta." values are handled specifically + if strings.HasPrefix(c.Prop, "grpc_meta.") { + if confProfile.GRPCMeta == nil { + confProfile.GRPCMeta = map[string]string{} + } + confProfile.GRPCMeta[strings.TrimPrefix(c.Prop, "grpc_meta.")] = c.Value + } else { + // Get reflect value + reflectVal, err := reflectEnvConfigProp(confProfile, c.Prop, false) + if err != nil { + return err + } + // Set it from string + switch reflectVal.Kind() { + case reflect.String: + reflectVal.SetString(c.Value) + case reflect.Pointer: + // Used for "tls", true makes an empty object, false sets nil + switch c.Value { + case "true": + // Only set if not set + if reflectVal.IsZero() { + reflectVal.Set(reflect.New(reflectVal.Type().Elem())) + } + case "false": + reflectVal.SetZero() + default: + return fmt.Errorf("must be 'true' or 'false' to set this property") + } + case reflect.Slice: + if reflectVal.Type().Elem().Kind() != reflect.Uint8 { + return fmt.Errorf("unexpected slice of type %v", reflectVal.Type()) + } + reflectVal.SetBytes([]byte(c.Value)) + case reflect.Bool: + if c.Value != "true" && c.Value != "false" { + return fmt.Errorf("must be 'true' or 'false' to set this property") + } + reflectVal.SetBool(c.Value == "true") + case reflect.Map: + return fmt.Errorf("must set each individual value of a map") + default: + return fmt.Errorf("unexpected type %v", reflectVal.Type()) + } + } + + // Save + return writeEnvConfigFile(cctx, conf) +} + +func envConfigProfileName(cctx *CommandContext) string { + if cctx.RootCommand.Profile != "" { + return cctx.RootCommand.Profile + } else if p, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_PROFILE"); p != "" { + return p + } + return envconfig.DefaultConfigFileProfile +} + +func loadEnvConfigProfile( + cctx *CommandContext, + profile string, + failIfNotFound bool, +) (*envconfig.ClientConfig, *envconfig.ClientConfigProfile, error) { + clientConfig, err := envconfig.LoadClientConfig(envconfig.LoadClientConfigOptions{ + ConfigFilePath: cctx.RootCommand.ConfigFile, + EnvLookup: cctx.Options.EnvLookup, + }) + if err != nil { + return nil, nil, err + } + + // Load profile + clientProfile := clientConfig.Profiles[profile] + if clientProfile == nil { + if failIfNotFound { + return nil, nil, fmt.Errorf("profile %q not found", profile) + } + clientProfile = &envconfig.ClientConfigProfile{} + clientConfig.Profiles[profile] = clientProfile + } + return &clientConfig, clientProfile, nil +} + +var envConfigPropsToFieldNames = map[string]string{ + "address": "Address", + "namespace": "Namespace", + "api_key": "APIKey", + "tls": "TLS", + "tls.disabled": "Disabled", + "tls.client_cert_path": "ClientCertPath", + "tls.client_cert_data": "ClientCertData", + "tls.client_key_path": "ClientKeyPath", + "tls.client_key_data": "ClientKeyData", + "tls.server_ca_cert_path": "ServerCACertPath", + "tls.server_ca_cert_data": "ServerCACertData", + "tls.server_name": "ServerName", + "tls.disable_host_verification": "DisableHostVerification", + "codec.endpoint": "Endpoint", + "codec.auth": "Auth", +} + +func reflectEnvConfigProp( + prof *envconfig.ClientConfigProfile, + prop string, + failIfParentNotFound bool, +) (reflect.Value, error) { + // Get field name + field := envConfigPropsToFieldNames[prop] + if field == "" { + return reflect.Value{}, fmt.Errorf("unknown property %q", prop) + } + + // Load reflect val + parentVal := reflect.ValueOf(prof) + if strings.HasPrefix(prop, "tls.") { + if prof.TLS == nil { + if failIfParentNotFound { + return reflect.Value{}, fmt.Errorf("no TLS options found") + } + prof.TLS = &envconfig.ClientConfigTLS{} + } + parentVal = reflect.ValueOf(prof.TLS) + } else if strings.HasPrefix(prop, "codec.") { + if prof.Codec == nil { + if failIfParentNotFound { + return reflect.Value{}, fmt.Errorf("no codec options found") + } + prof.Codec = &envconfig.ClientConfigCodec{} + } + parentVal = reflect.ValueOf(prof.Codec) + } + + // Return reflected field + if parentVal.Kind() == reflect.Pointer { + parentVal = parentVal.Elem() + } + return parentVal.FieldByName(field), nil +} + +func writeEnvConfigFile(cctx *CommandContext, conf *envconfig.ClientConfig) error { + // Get file + configFile := cctx.RootCommand.ConfigFile + if configFile == "" { + configFile, _ = cctx.Options.EnvLookup.LookupEnv("TEMPORAL_CONFIG_FILE") + if configFile == "" { + var err error + if configFile, err = envconfig.DefaultConfigFilePath(); err != nil { + return err + } + } + } + + // Convert to TOML + b, err := conf.ToTOML(envconfig.ClientConfigToTOMLOptions{}) + if err != nil { + return fmt.Errorf("failed building TOML: %w", err) + } + + // Write to file, making dirs as needed + cctx.Logger.Info("Writing config file", "file", configFile) + if err := os.MkdirAll(filepath.Dir(configFile), 0700); err != nil { + return fmt.Errorf("failed making config file parent dirs: %w", err) + } else if err := os.WriteFile(configFile, b, 0600); err != nil { + return fmt.Errorf("failed writing config file: %w", err) + } + return nil +} diff --git a/temporalcli/commands.config_test.go b/temporalcli/commands.config_test.go new file mode 100644 index 000000000..40bd5b4af --- /dev/null +++ b/temporalcli/commands.config_test.go @@ -0,0 +1,374 @@ +package temporalcli_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/BurntSushi/toml" +) + +func TestConfig_Get(t *testing.T) { + h := NewCommandHarness(t) + defer h.Close() + env := EnvLookupMap{} + h.Options.EnvLookup = env + + // Put some data in temp file + f, err := os.CreateTemp("", "") + h.NoError(err) + defer os.Remove(f.Name()) + _, err = f.Write([]byte(` +[profile.foo] +address = "my-address" +namespace = "my-namespace" +api_key = "my-api-key" +codec = { endpoint = "my-endpoint", auth = "my-auth" } +grpc_meta = { some-heAder1 = "some-value1", some-header2 = "some-value2", some_heaDer3 = "some-value3" } +some_future_key = "some future value not handled" + +[profile.foo.tls] +disabled = true +client_cert_path = "my-client-cert-path" +client_cert_data = "my-client-cert-data" +client_key_path = "my-client-key-path" +client_key_data = "my-client-key-data" +server_ca_cert_path = "my-server-ca-cert-path" +server_ca_cert_data = "my-server-ca-cert-data" +# Intentionally absent +# server_name = "my-server-name" +disable_host_verification = true`)) + f.Close() + h.NoError(err) + env["TEMPORAL_CONFIG_FILE"] = f.Name() + + // Bad profile default + res := h.Execute("config", "get") + h.ErrorContains(res.Err, `profile "default" not found`) + + // Bad profile env var + env["TEMPORAL_PROFILE"] = "env-prof" + res = h.Execute("config", "get") + h.ErrorContains(res.Err, `profile "env-prof" not found`) + delete(env, "TEMPORAL_PROFILE") + + // Bad profile CLI arg + res = h.Execute("config", "get", "--profile", "arg-prof") + h.ErrorContains(res.Err, `profile "arg-prof" not found`) + + // Unknown prop + env["TEMPORAL_PROFILE"] = "foo" + res = h.Execute("config", "get", "--prop", "blah") + h.ErrorContains(res.Err, `unknown property "blah"`) + + // Unknown meta + res = h.Execute("config", "get", "--prop", "grpc_meta.wrong") + h.ErrorContains(res.Err, `unknown property "grpc_meta.wrong"`) + res = h.Execute("config", "get", "--prop", "grpc_meta.some-heAder1") + h.ErrorContains(res.Err, `unknown property "grpc_meta.some-heAder1"`) + + // All props + expectedJSON := map[string]any{ + "address": "my-address", + "namespace": "my-namespace", + "api_key": "my-api-key", + "codec.endpoint": "my-endpoint", + "codec.auth": "my-auth", + "grpc_meta.some-header1": "some-value1", + "tls": true, + "tls.disabled": true, + "tls.client_cert_path": "my-client-cert-path", + "tls.client_cert_data": []byte("my-client-cert-data"), + "tls.client_key_path": "my-client-key-path", + "tls.client_key_data": []byte("my-client-key-data"), + "tls.server_ca_cert_path": "my-server-ca-cert-path", + "tls.server_ca_cert_data": []byte("my-server-ca-cert-data"), + "tls.server_name": "", + "tls.disable_host_verification": true, + } + expectedNonJSON := make(map[string]any, len(expectedJSON)) + for prop, expectedVal := range expectedJSON { + if b, ok := expectedVal.([]byte); ok { + expectedVal = "bytes(" + base64.StdEncoding.EncodeToString(b) + ")" + } else { + expectedNonJSON[prop] = expectedVal + } + } + + // JSON individual + for prop, expectedVal := range expectedJSON { + res = h.Execute("config", "get", "--prop", prop, "-o", "json") + b, _ := json.Marshal(expectedVal) + h.JSONEq(fmt.Sprintf(`{"property": %q, "value": %s}`, prop, b), res.Stdout.String()) + } + + // Non-JSON individual + for prop, expectedVal := range expectedNonJSON { + res := h.Execute("config", "get", "--prop", prop) + h.NoError(res.Err) + h.ContainsOnSameLine(res.Stdout.String(), prop, fmt.Sprintf("%v", expectedVal)) + } + + // JSON all together + res = h.Execute("config", "get", "-o", "json") + h.NoError(res.Err) + h.JSONEq(`{ + "address": "my-address", + "api_key": "my-api-key", + "codec": { + "auth": "my-auth", + "endpoint": "my-endpoint" + }, + "grpc_meta": { + "some-header1": "some-value1", + "some-header2": "some-value2", + "some-header3": "some-value3" + }, + "namespace": "my-namespace", + "tls": { + "client_cert_data": "my-client-cert-data", + "client_cert_path": "my-client-cert-path", + "client_key_data": "my-client-key-data", + "client_key_path": "my-client-key-path", + "disable_host_verification": true, + "disabled": true, + "server_ca_cert_data": "my-server-ca-cert-data", + "server_ca_cert_path": "my-server-ca-cert-path" + } + }`, res.Stdout.String()) + + // Non-JSON all together + res = h.Execute("config", "get") + h.NoError(res.Err) + for prop, expectedVal := range expectedNonJSON { + // Server name is excluded because it's a zero val + if prop == "tls.server_name" { + continue + } + h.ContainsOnSameLine(res.Stdout.String(), prop, fmt.Sprintf("%v", expectedVal)) + } +} + +func TestConfig_TLS_Boolean(t *testing.T) { + h := NewCommandHarness(t) + defer h.Close() + + // Put some data in temp file + f, err := os.CreateTemp("", "") + h.NoError(err) + defer os.Remove(f.Name()) + _, err = f.Write([]byte(` +[profile.foo] +address = "my-address" + +[profile.foo.tls]`)) + f.Close() + h.NoError(err) + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": f.Name(), "TEMPORAL_PROFILE": "foo"} + + // Check that it shows TLS as true + res := h.Execute("config", "get", "--prop", "tls") + h.NoError(res.Err) + h.ContainsOnSameLine(res.Stdout.String(), "tls", "true") + + // Now set it as false and confirm deleted + res = h.Execute("config", "set", "--prop", "tls", "--value", "false") + h.NoError(res.Err) + b, err := os.ReadFile(f.Name()) + h.NoError(err) + h.NotContains(string(b), "tls") + res = h.Execute("config", "get", "--prop", "tls") + h.NoError(res.Err) + h.ContainsOnSameLine(res.Stdout.String(), "tls", "false") + + // Set it as true and confirm it is there again + res = h.Execute("config", "set", "--prop", "tls", "--value", "true") + h.NoError(res.Err) + b, err = os.ReadFile(f.Name()) + h.NoError(err) + h.Contains(string(b), "tls") + res = h.Execute("config", "get", "--prop", "tls") + h.NoError(res.Err) + h.ContainsOnSameLine(res.Stdout.String(), "tls", "true") + + // Delete and confirm gone + res = h.Execute("config", "delete", "--prop", "tls") + h.NoError(res.Err) + b, err = os.ReadFile(f.Name()) + h.NoError(err) + h.NotContains(string(b), "tls") + res = h.Execute("config", "get", "--prop", "tls") + h.NoError(res.Err) + h.ContainsOnSameLine(res.Stdout.String(), "tls", "false") +} + +func TestConfig_Delete(t *testing.T) { + h := NewCommandHarness(t) + defer h.Close() + + // Put some data in temp file + f, err := os.CreateTemp("", "") + h.NoError(err) + defer os.Remove(f.Name()) + _, err = f.Write([]byte(` +[profile.foo] +address = "my-address" +namespace = "my-namespace" + +[profile.foo.tls]`)) + f.Close() + h.NoError(err) + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": f.Name(), "TEMPORAL_PROFILE": "foo"} + + // Confirm address and namespace is there + res := h.Execute("config", "get") + h.NoError(res.Err) + h.Contains(res.Stdout.String(), "my-address") + h.Contains(res.Stdout.String(), "my-namespace") + + // Delete namespace and confirm gone but address still there + res = h.Execute("config", "delete", "--prop", "namespace") + h.NoError(res.Err) + res = h.Execute("config", "get") + h.NoError(res.Err) + h.Contains(res.Stdout.String(), "my-address") + h.NotContains(res.Stdout.String(), "my-namespace") + + // Delete entire profile + res = h.Execute("config", "delete", "--profile", "foo") + h.NoError(res.Err) + res = h.Execute("config", "get") + h.ErrorContains(res.Err, `profile "foo" not found`) +} + +func TestConfig_List(t *testing.T) { + h := NewCommandHarness(t) + defer h.Close() + + // Put some data in temp file + f, err := os.CreateTemp("", "") + h.NoError(err) + defer os.Remove(f.Name()) + _, err = f.Write([]byte(` +[profile.foo] +address = "my-address-foo" +[profile.bar] +address = "my-address-bar"`)) + f.Close() + h.NoError(err) + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": f.Name()} + + // Confirm both profiles are there + res := h.Execute("config", "list") + h.NoError(res.Err) + h.Contains(res.Stdout.String(), "foo") + h.Contains(res.Stdout.String(), "bar") + + // Same in JSON + res = h.Execute("config", "list", "-o", "json") + h.NoError(res.Err) + h.Contains(res.Stdout.String(), `"foo"`) + h.Contains(res.Stdout.String(), `"bar"`) + + // Now delete and try again + res = h.Execute("config", "delete", "--profile", "foo") + h.NoError(res.Err) + res = h.Execute("config", "list") + h.NoError(res.Err) + h.NotContains(res.Stdout.String(), "foo") + h.Contains(res.Stdout.String(), "bar") + + // Same in JSON + res = h.Execute("config", "list", "-o", "json") + h.NoError(res.Err) + h.NotContains(res.Stdout.String(), `"foo"`) + h.Contains(res.Stdout.String(), `"bar"`) +} + +func TestConfig_Set(t *testing.T) { + h := NewCommandHarness(t) + defer h.Close() + + // Create a temp file then delete it immediately to confirm set lazily + // creates as needed + f, err := os.CreateTemp("", "") + h.NoError(err) + h.NoError(f.Close()) + h.NoError(os.Remove(f.Name())) + _, err = os.Stat(f.Name()) + h.True(os.IsNotExist(err)) + // Also remove again at the end + defer os.Remove(f.Name()) + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": f.Name()} + + // Now set an address which will be on default profile and confirm in file + // and "get" + res := h.Execute("config", "set", "--prop", "address", "--value", "some-address") + h.NoError(res.Err) + b, err := os.ReadFile(f.Name()) + h.NoError(err) + h.Contains(string(b), "[profile.default]") + h.Contains(string(b), `"some-address"`) + res = h.Execute("config", "get", "--prop", "address") + h.NoError(res.Err) + h.ContainsOnSameLine(res.Stdout.String(), "address", "some-address") + + // Set a bunch of other things + toSet := map[string]string{ + "address": "my-address", + "namespace": "my-namespace", + "api_key": "my-api-key", + "codec.endpoint": "my-endpoint", + "codec.auth": "my-auth", + "grpc_meta.sOme_header1": "some-value1", + "tls": "true", + "tls.disabled": "true", + "tls.client_cert_path": "my-client-cert-path", + "tls.client_cert_data": "my-client-cert-data", + "tls.client_key_path": "my-client-key-path", + "tls.client_key_data": "my-client-key-data", + "tls.server_ca_cert_path": "my-server-ca-cert-path", + "tls.server_ca_cert_data": "my-server-ca-cert-data", + "tls.disable_host_verification": "true", + } + for k, v := range toSet { + res = h.Execute("config", "set", "--prop", k, "--value", v) + h.NoError(res.Err) + } + + // TOML parse that whole thing and confirm equals + b, err = os.ReadFile(f.Name()) + h.NoError(err) + var all any + h.NoError(toml.Unmarshal(b, &all)) + h.Equal( + map[string]any{ + "profile": map[string]any{ + "default": map[string]any{ + "address": "my-address", + "namespace": "my-namespace", + "api_key": "my-api-key", + "tls": map[string]any{ + "disabled": true, + "client_cert_path": "my-client-cert-path", + "client_cert_data": "my-client-cert-data", + "client_key_path": "my-client-key-path", + "client_key_data": "my-client-key-data", + "server_ca_cert_path": "my-server-ca-cert-path", + "server_ca_cert_data": "my-server-ca-cert-data", + "disable_host_verification": true, + }, + "codec": map[string]any{ + "endpoint": "my-endpoint", + "auth": "my-auth", + }, + "grpc_meta": map[string]any{ + "some-header1": "some-value1", + }, + }, + }, + }, + all) +} diff --git a/temporalcli/commands.env.go b/temporalcli/commands.env.go index 6d5363981..c39921689 100644 --- a/temporalcli/commands.env.go +++ b/temporalcli/commands.env.go @@ -2,10 +2,13 @@ package temporalcli import ( "fmt" + "os" + "path/filepath" "sort" "strings" "github.com/temporalio/cli/temporalcli/internal/printer" + "gopkg.in/yaml.v3" ) func (c *TemporalEnvCommand) envNameAndKey(cctx *CommandContext, args []string, keyFlag string) (string, string, error) { @@ -43,16 +46,16 @@ func (c *TemporalEnvDeleteCommand) run(cctx *CommandContext, args []string) erro } // Env is guaranteed to already be present - env, _ := cctx.EnvConfigValues[envName] + env, _ := cctx.DeprecatedEnvConfigValues[envName] // User can remove single flag or all in env if key != "" { cctx.Logger.Info("Deleting env property", "env", envName, "property", key) delete(env, key) } else { cctx.Logger.Info("Deleting env", "env", env) - delete(cctx.EnvConfigValues, envName) + delete(cctx.DeprecatedEnvConfigValues, envName) } - return cctx.WriteEnvConfigToFile() + return writeDeprecatedEnvConfigToFile(cctx) } func (c *TemporalEnvGetCommand) run(cctx *CommandContext, args []string) error { @@ -62,7 +65,7 @@ func (c *TemporalEnvGetCommand) run(cctx *CommandContext, args []string) error { } // Env is guaranteed to already be present - env, _ := cctx.EnvConfigValues[envName] + env, _ := cctx.DeprecatedEnvConfigValues[envName] type prop struct { Property string `json:"property"` Value string `json:"value"` @@ -86,8 +89,8 @@ func (c *TemporalEnvListCommand) run(cctx *CommandContext, args []string) error type env struct { Name string `json:"name"` } - envs := make([]env, 0, len(cctx.EnvConfigValues)) - for k := range cctx.EnvConfigValues { + envs := make([]env, 0, len(cctx.DeprecatedEnvConfigValues)) + for k := range cctx.DeprecatedEnvConfigValues { envs = append(envs, env{Name: k}) } // Print as table @@ -120,13 +123,58 @@ func (c *TemporalEnvSetCommand) run(cctx *CommandContext, args []string) error { return fmt.Errorf("too many arguments provided; see --help") } - if cctx.EnvConfigValues == nil { - cctx.EnvConfigValues = map[string]map[string]string{} + if cctx.DeprecatedEnvConfigValues == nil { + cctx.DeprecatedEnvConfigValues = map[string]map[string]string{} } - if cctx.EnvConfigValues[envName] == nil { - cctx.EnvConfigValues[envName] = map[string]string{} + if cctx.DeprecatedEnvConfigValues[envName] == nil { + cctx.DeprecatedEnvConfigValues[envName] = map[string]string{} } cctx.Logger.Info("Setting env property", "env", envName, "property", key, "value", value) - cctx.EnvConfigValues[envName][key] = value - return cctx.WriteEnvConfigToFile() + cctx.DeprecatedEnvConfigValues[envName][key] = value + return writeDeprecatedEnvConfigToFile(cctx) +} + +func writeDeprecatedEnvConfigToFile(cctx *CommandContext) error { + if cctx.Options.DeprecatedEnvConfig.EnvConfigFile == "" { + return fmt.Errorf("unable to find place for env file (unknown HOME dir)") + } + cctx.Logger.Info("Writing env file", "file", cctx.Options.DeprecatedEnvConfig.EnvConfigFile) + return writeDeprecatedEnvConfigFile(cctx.Options.DeprecatedEnvConfig.EnvConfigFile, cctx.DeprecatedEnvConfigValues) +} + +// May be empty result if can't get user home dir +func defaultDeprecatedEnvConfigFile(appName, configName string) string { + // No env file if no $HOME + if dir, err := os.UserHomeDir(); err == nil { + return filepath.Join(dir, ".config", appName, configName+".yaml") + } + return "" +} + +func readDeprecatedEnvConfigFile(file string) (env map[string]map[string]string, err error) { + b, err := os.ReadFile(file) + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("failed reading env file: %w", err) + } + var m map[string]map[string]map[string]string + if err := yaml.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("failed unmarshalling env YAML: %w", err) + } + return m["env"], nil +} + +func writeDeprecatedEnvConfigFile(file string, env map[string]map[string]string) error { + b, err := yaml.Marshal(map[string]any{"env": env}) + if err != nil { + return fmt.Errorf("failed marshaling YAML: %w", err) + } + // Make parent directories as needed + if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + return fmt.Errorf("failed making env file parent dirs: %w", err) + } else if err := os.WriteFile(file, b, 0600); err != nil { + return fmt.Errorf("failed writing env file: %w", err) + } + return nil } diff --git a/temporalcli/commands.env_test.go b/temporalcli/commands.env_test.go index 0956485e8..504ebc1da 100644 --- a/temporalcli/commands.env_test.go +++ b/temporalcli/commands.env_test.go @@ -12,21 +12,21 @@ func TestEnv_Simple(t *testing.T) { defer h.Close() // Non-existent file, no env found for get - h.Options.EnvConfigFile = "does-not-exist" + h.Options.DeprecatedEnvConfig.EnvConfigFile = "does-not-exist" res := h.Execute("env", "get", "--env", "myenv1") h.ErrorContains(res.Err, `environment "myenv1" not found`) // Temp file for env tmpFile, err := os.CreateTemp("", "") h.NoError(err) - h.Options.EnvConfigFile = tmpFile.Name() - defer os.Remove(h.Options.EnvConfigFile) + h.Options.DeprecatedEnvConfig.EnvConfigFile = tmpFile.Name() + defer os.Remove(h.Options.DeprecatedEnvConfig.EnvConfigFile) // Store a key res = h.Execute("env", "set", "--env", "myenv1", "-k", "foo", "-v", "bar") h.NoError(res.Err) // Confirm file is YAML with expected values - b, err := os.ReadFile(h.Options.EnvConfigFile) + b, err := os.ReadFile(h.Options.DeprecatedEnvConfig.EnvConfigFile) h.NoError(err) var yamlVals map[string]map[string]map[string]string h.NoError(yaml.Unmarshal(b, &yamlVals)) @@ -86,8 +86,8 @@ func TestEnv_InputValidation(t *testing.T) { // myenv1 needs to exist tmpFile, err := os.CreateTemp("", "") h.NoError(err) - h.Options.EnvConfigFile = tmpFile.Name() - defer os.Remove(h.Options.EnvConfigFile) + h.Options.DeprecatedEnvConfig.EnvConfigFile = tmpFile.Name() + defer os.Remove(h.Options.DeprecatedEnvConfig.EnvConfigFile) res := h.Execute("env", "set", "--env", "myenv1", "-k", "foo", "-v", "bar") h.NoError(res.Err) diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index a73a480c4..b57def06e 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -36,34 +36,20 @@ type ClientOptions struct { func (v *ClientOptions) buildFlags(cctx *CommandContext, f *pflag.FlagSet) { f.StringVar(&v.Address, "address", "127.0.0.1:7233", "Temporal Service gRPC endpoint.") - cctx.BindFlagEnvVar(f.Lookup("address"), "TEMPORAL_ADDRESS") f.StringVarP(&v.Namespace, "namespace", "n", "default", "Temporal Service Namespace.") - cctx.BindFlagEnvVar(f.Lookup("namespace"), "TEMPORAL_NAMESPACE") f.StringVar(&v.ApiKey, "api-key", "", "API key for request.") - cctx.BindFlagEnvVar(f.Lookup("api-key"), "TEMPORAL_API_KEY") - f.StringArrayVar(&v.GrpcMeta, "grpc-meta", nil, "HTTP headers for requests. format as a `KEY=VALUE` pair May be passed multiple times to set multiple headers.") - f.BoolVar(&v.Tls, "tls", false, "Enable base TLS encryption. Does not have additional options like mTLS or client certs.") - cctx.BindFlagEnvVar(f.Lookup("tls"), "TEMPORAL_TLS") + f.StringArrayVar(&v.GrpcMeta, "grpc-meta", nil, "HTTP headers for requests. format as a `KEY=VALUE` pair May be passed multiple times to set multiple headers. Can also be made available via environment variable as `TEMPORAL_GRPC_META_[name]`.") + f.BoolVar(&v.Tls, "tls", false, "Enable base TLS encryption. Does not have additional options like mTLS or client certs. Unlike some other options, if this is present and set explicitly to false, it can still be overridden by config file or environment variables.") f.StringVar(&v.TlsCertPath, "tls-cert-path", "", "Path to x509 certificate. Can't be used with --tls-cert-data.") - cctx.BindFlagEnvVar(f.Lookup("tls-cert-path"), "TEMPORAL_TLS_CERT") f.StringVar(&v.TlsCertData, "tls-cert-data", "", "Data for x509 certificate. Can't be used with --tls-cert-path.") - cctx.BindFlagEnvVar(f.Lookup("tls-cert-data"), "TEMPORAL_TLS_CERT_DATA") f.StringVar(&v.TlsKeyPath, "tls-key-path", "", "Path to x509 private key. Can't be used with --tls-key-data.") - cctx.BindFlagEnvVar(f.Lookup("tls-key-path"), "TEMPORAL_TLS_KEY") f.StringVar(&v.TlsKeyData, "tls-key-data", "", "Private certificate key data. Can't be used with --tls-key-path.") - cctx.BindFlagEnvVar(f.Lookup("tls-key-data"), "TEMPORAL_TLS_KEY_DATA") f.StringVar(&v.TlsCaPath, "tls-ca-path", "", "Path to server CA certificate. Can't be used with --tls-ca-data.") - cctx.BindFlagEnvVar(f.Lookup("tls-ca-path"), "TEMPORAL_TLS_CA") f.StringVar(&v.TlsCaData, "tls-ca-data", "", "Data for server CA certificate. Can't be used with --tls-ca-path.") - cctx.BindFlagEnvVar(f.Lookup("tls-ca-data"), "TEMPORAL_TLS_CA_DATA") f.BoolVar(&v.TlsDisableHostVerification, "tls-disable-host-verification", false, "Disable TLS host-name verification.") - cctx.BindFlagEnvVar(f.Lookup("tls-disable-host-verification"), "TEMPORAL_TLS_DISABLE_HOST_VERIFICATION") f.StringVar(&v.TlsServerName, "tls-server-name", "", "Override target TLS server name.") - cctx.BindFlagEnvVar(f.Lookup("tls-server-name"), "TEMPORAL_TLS_SERVER_NAME") f.StringVar(&v.CodecEndpoint, "codec-endpoint", "", "Remote Codec Server endpoint.") - cctx.BindFlagEnvVar(f.Lookup("codec-endpoint"), "TEMPORAL_CODEC_ENDPOINT") f.StringVar(&v.CodecAuth, "codec-auth", "", "Authorization header for Codec Server requests.") - cctx.BindFlagEnvVar(f.Lookup("codec-auth"), "TEMPORAL_CODEC_AUTH") } type OverlapPolicyOptions struct { @@ -185,7 +171,8 @@ type WorkflowStartOptions struct { } func (v *WorkflowStartOptions) buildFlags(cctx *CommandContext, f *pflag.FlagSet) { - f.StringVar(&v.Cron, "cron", "", "Cron schedule for the Workflow. Deprecated. Use Schedules instead.") + f.StringVar(&v.Cron, "cron", "", "Cron schedule for the Workflow.") + _ = f.MarkDeprecated("cron", "Use Schedules instead.") f.BoolVar(&v.FailExisting, "fail-existing", false, "Fail if the Workflow already exists.") v.StartDelay = 0 f.Var(&v.StartDelay, "start-delay", "Delay before starting the Workflow Execution. Can't be used with cron schedules. If the Workflow receives a signal or update prior to this time, the Workflow Execution starts immediately.") @@ -263,13 +250,17 @@ func (v *NexusEndpointConfigOptions) buildFlags(cctx *CommandContext, f *pflag.F f.StringVar(&v.DescriptionFile, "description-file", "", "Path to the Nexus Endpoint description file. The contents of the description file may use Markdown formatting.") f.StringVar(&v.TargetNamespace, "target-namespace", "", "Namespace where a handler Worker polls for Nexus tasks.") f.StringVar(&v.TargetTaskQueue, "target-task-queue", "", "Task Queue that a handler Worker polls for Nexus tasks.") - f.StringVar(&v.TargetUrl, "target-url", "", "An external Nexus Endpoint that receives forwarded Nexus requests. May be used as an alternative to `--target-namespace` and `--target-task-queue`.") + f.StringVar(&v.TargetUrl, "target-url", "", "An external Nexus Endpoint that receives forwarded Nexus requests. May be used as an alternative to `--target-namespace` and `--target-task-queue`. EXPERIMENTAL.") } type TemporalCommand struct { Command cobra.Command Env string EnvFile string + ConfigFile string + Profile string + DisableConfigFile bool + DisableConfigEnv bool LogLevel StringEnum LogFormat StringEnum Output StringEnum @@ -291,6 +282,7 @@ func NewTemporalCommand(cctx *CommandContext) *TemporalCommand { s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewTemporalActivityCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalBatchCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalConfigCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalEnvCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalOperatorCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleCommand(cctx, &s).Command) @@ -300,6 +292,10 @@ func NewTemporalCommand(cctx *CommandContext) *TemporalCommand { s.Command.PersistentFlags().StringVar(&s.Env, "env", "default", "Active environment name (`ENV`).") cctx.BindFlagEnvVar(s.Command.PersistentFlags().Lookup("env"), "TEMPORAL_ENV") s.Command.PersistentFlags().StringVar(&s.EnvFile, "env-file", "", "Path to environment settings file. Defaults to `$HOME/.config/temporalio/temporal.yaml`.") + s.Command.PersistentFlags().StringVar(&s.ConfigFile, "config-file", "", "File path to read TOML config from, defaults to `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is defined as `$HOME/.config` on Unix, \"$HOME/Library/Application Support\" on macOS, and %AppData% on Windows. EXPERIMENTAL.") + s.Command.PersistentFlags().StringVar(&s.Profile, "profile", "", "Profile to use for config file. EXPERIMENTAL.") + s.Command.PersistentFlags().BoolVar(&s.DisableConfigFile, "disable-config-file", false, "If set, disables loading environment config from config file. EXPERIMENTAL.") + s.Command.PersistentFlags().BoolVar(&s.DisableConfigEnv, "disable-config-env", false, "If set, disables loading environment config from environment variables. EXPERIMENTAL.") s.LogLevel = NewStringEnum([]string{"debug", "info", "warn", "error", "never"}, "info") s.Command.PersistentFlags().Var(&s.LogLevel, "log-level", "Log level. Default is \"info\" for most commands and \"warn\" for `server start-dev`. Accepted values: debug, info, warn, error, never.") s.LogFormat = NewStringEnum([]string{"text", "json", "pretty"}, "text") @@ -693,6 +689,139 @@ func NewTemporalBatchTerminateCommand(cctx *CommandContext, parent *TemporalBatc return &s } +type TemporalConfigCommand struct { + Parent *TemporalCommand + Command cobra.Command +} + +func NewTemporalConfigCommand(cctx *CommandContext, parent *TemporalCommand) *TemporalConfigCommand { + var s TemporalConfigCommand + s.Parent = parent + s.Command.Use = "config" + s.Command.Short = "Manage config files (EXPERIMENTAL)" + if hasHighlighting { + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file is at \x1b[1m$CONFIG_PATH/temporal/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m." + } else { + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file is at `$CONFIG_PATH/temporal/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`." + } + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewTemporalConfigDeleteCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalConfigGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalConfigListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalConfigSetCommand(cctx, &s).Command) + return &s +} + +type TemporalConfigDeleteCommand struct { + Parent *TemporalConfigCommand + Command cobra.Command + Prop string +} + +func NewTemporalConfigDeleteCommand(cctx *CommandContext, parent *TemporalConfigCommand) *TemporalConfigDeleteCommand { + var s TemporalConfigDeleteCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "delete [flags]" + s.Command.Short = "Delete a config file property or an entire config profile (EXPERIMENTAL)\n" + if hasHighlighting { + s.Command.Long = "Remove a full profile entirely _or_ remove a property within a profile.\nWhen deleting an entire profile, the \x1b[1m--profile\x1b[0m must be set explicitly.\n\n\x1b[1mtemporal env delete \\\n --profile my-profile\x1b[0m\n\nor\n\n\x1b[1mtemporal env delete \\\n --prop tls.client_cert_path\x1b[0m" + } else { + s.Command.Long = "Remove a full profile entirely _or_ remove a property within a profile.\nWhen deleting an entire profile, the `--profile` must be set explicitly.\n\n```\ntemporal env delete \\\n --profile my-profile\n```\n\nor\n\n```\ntemporal env delete \\\n --prop tls.client_cert_path\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to delete. If unset, deletes entire profile.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type TemporalConfigGetCommand struct { + Parent *TemporalConfigCommand + Command cobra.Command + Prop string +} + +func NewTemporalConfigGetCommand(cctx *CommandContext, parent *TemporalConfigCommand) *TemporalConfigGetCommand { + var s TemporalConfigGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "Show config file properties (EXPERIMENTAL)" + if hasHighlighting { + s.Command.Long = "Display specific properties or the entire profile.\n\n\x1b[1mtemporal config get \\\n --prop address\x1b[0m\n\nor\n\n\x1b[1mtemporal config get\x1b[0m" + } else { + s.Command.Long = "Display specific properties or the entire profile.\n\n```\ntemporal config get \\\n --prop address\n```\n\nor\n\n```\ntemporal config get\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to get.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type TemporalConfigListCommand struct { + Parent *TemporalConfigCommand + Command cobra.Command +} + +func NewTemporalConfigListCommand(cctx *CommandContext, parent *TemporalConfigCommand) *TemporalConfigListCommand { + var s TemporalConfigListCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "list [flags]" + s.Command.Short = "Show config file profiles (EXPERIMENTAL)" + if hasHighlighting { + s.Command.Long = "List profile names in the config file.\n\n\x1b[1mtemporal config list\x1b[0m" + } else { + s.Command.Long = "List profile names in the config file.\n\n```\ntemporal config list\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type TemporalConfigSetCommand struct { + Parent *TemporalConfigCommand + Command cobra.Command + Prop string + Value string +} + +func NewTemporalConfigSetCommand(cctx *CommandContext, parent *TemporalConfigCommand) *TemporalConfigSetCommand { + var s TemporalConfigSetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "set [flags]" + s.Command.Short = "Set config file properties (EXPERIMENTAL)" + if hasHighlighting { + s.Command.Long = "Assign a value to a property and store it in the config file:\n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m" + } else { + s.Command.Long = "Assign a value to a property and store it in the config file:\n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Property name. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "prop") + s.Command.Flags().StringVarP(&s.Value, "value", "v", "", "Property value. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "value") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type TemporalEnvCommand struct { Parent *TemporalCommand Command cobra.Command @@ -2735,7 +2864,7 @@ func NewTemporalWorkflowListCommand(cctx *CommandContext, parent *TemporalWorkfl } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") - s.Command.Flags().BoolVar(&s.Archived, "archived", false, "Limit output to archived Workflow Executions.") + s.Command.Flags().BoolVar(&s.Archived, "archived", false, "Limit output to archived Workflow Executions. EXPERIMENTAL.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Workflow Executions to display.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { @@ -2816,7 +2945,8 @@ func NewTemporalWorkflowResetCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for reset. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "reason") s.ReapplyType = NewStringEnum([]string{"All", "Signal", "None"}, "All") - s.Command.Flags().Var(&s.ReapplyType, "reapply-type", "Types of events to re-apply after reset point. Deprecated. Use --reapply-exclude instead. Accepted values: All, Signal, None.") + s.Command.Flags().Var(&s.ReapplyType, "reapply-type", "Types of events to re-apply after reset point. Accepted values: All, Signal, None.") + _ = s.Command.Flags().MarkDeprecated("reapply-type", "Use --reapply-exclude instead.") s.ReapplyExclude = NewStringEnumArray([]string{"All", "Signal", "Update"}, []string{}) s.Command.Flags().Var(&s.ReapplyExclude, "reapply-exclude", "Exclude these event types from re-application. Accepted values: All, Signal, Update.") s.Type = NewStringEnum([]string{"FirstWorkflowTask", "LastWorkflowTask", "LastContinuedAsNew", "BuildId"}, "") diff --git a/temporalcli/commands.go b/temporalcli/commands.go index b9ba6322a..5e28c83cb 100644 --- a/temporalcli/commands.go +++ b/temporalcli/commands.go @@ -10,7 +10,6 @@ import ( "log/slog" "os" "os/signal" - "path/filepath" "slices" "strings" "syscall" @@ -25,12 +24,12 @@ import ( "go.temporal.io/api/common/v1" "go.temporal.io/api/failure/v1" "go.temporal.io/api/temporalproto" + "go.temporal.io/sdk/contrib/envconfig" "go.temporal.io/sdk/temporal" "go.temporal.io/server/common/headers" "google.golang.org/grpc" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" - "gopkg.in/yaml.v3" ) // Version is the value put as the default command version. This is often @@ -40,9 +39,9 @@ var Version = "0.0.0-DEV" type CommandContext struct { // This context is closed on interrupt context.Context - Options CommandOptions - EnvConfigValues map[string]map[string]string - FlagsWithEnvVars []*pflag.Flag + Options CommandOptions + DeprecatedEnvConfigValues map[string]map[string]string + FlagsWithEnvVars []*pflag.Flag // These values may not be available until after pre-run of main command Printer *printer.Printer @@ -53,20 +52,18 @@ type CommandContext struct { // Is set to true if any command actually started running. This is a hack to workaround the fact // that cobra does not properly exit nonzero if an unknown command/subcommand is given. ActuallyRanCommand bool + + // Root command only set inside of pre-run + RootCommand *TemporalCommand } type CommandOptions struct { // If empty, assumed to be os.Args[1:] Args []string - // If unset, defaulted to $HOME/.config/temporalio/temporal.yaml - EnvConfigFile string - // If unset, attempts to extract --env from Args (which defaults to "default") - EnvConfigName string - // If true, does not do any env config reading - DisableEnvConfig bool - // If nil, os.LookupEnv is used. This is for environment variables and not - // related to env config stuff above. - LookupEnv func(string) (string, bool) + // Deprecated `--env` and `--env-file` approach + DeprecatedEnvConfig DeprecatedEnvConfig + // If nil, [envconfig.EnvLookupOS] is used. + EnvLookup envconfig.EnvLookup // These three fields below default to OS values Stdin io.Reader @@ -79,6 +76,15 @@ type CommandOptions struct { AdditionalClientGRPCDialOptions []grpc.DialOption } +type DeprecatedEnvConfig struct { + // If unset, defaulted to $HOME/.config/temporalio/temporal.yaml + EnvConfigFile string + // If unset, attempts to extract --env from Args (which defaults to "default") + EnvConfigName string + // If true, does not do any env config reading + DisableEnvConfig bool +} + // NewCommandContext creates a CommandContext for use by the rest of the CLI. // Among other things, this parses the env config file and modifies // options/flags according to the parameters set there. @@ -104,8 +110,8 @@ func (c *CommandContext) preprocessOptions() error { if len(c.Options.Args) == 0 { c.Options.Args = os.Args[1:] } - if c.Options.LookupEnv == nil { - c.Options.LookupEnv = os.LookupEnv + if c.Options.EnvLookup == nil { + c.Options.EnvLookup = envconfig.EnvLookupOS } if c.Options.Stdin == nil { @@ -140,37 +146,38 @@ func (c *CommandContext) preprocessOptions() error { // Why last? Callers need the CommandContext to be usable no matter what, // because they rely on it to print errors even if env parsing fails. In // that situation, we will return both the CommandContext AND an error. - if !c.Options.DisableEnvConfig { - if c.Options.EnvConfigFile == "" { + if !c.Options.DeprecatedEnvConfig.DisableEnvConfig { + if c.Options.DeprecatedEnvConfig.EnvConfigFile == "" { // Default to --env-file, prefetched from CLI args for i, arg := range c.Options.Args { if arg == "--env-file" && i+1 < len(c.Options.Args) { - c.Options.EnvConfigFile = c.Options.Args[i+1] + c.Options.DeprecatedEnvConfig.EnvConfigFile = c.Options.Args[i+1] } } // Default to inside home dir - if c.Options.EnvConfigFile == "" { - c.Options.EnvConfigFile = defaultEnvConfigFile("temporalio", "temporal") + if c.Options.DeprecatedEnvConfig.EnvConfigFile == "" { + c.Options.DeprecatedEnvConfig.EnvConfigFile = defaultDeprecatedEnvConfigFile("temporalio", "temporal") } } - if c.Options.EnvConfigName == "" { - c.Options.EnvConfigName = "default" - if envVal, ok := c.Options.LookupEnv(temporalEnv); ok { - c.Options.EnvConfigName = envVal + if c.Options.DeprecatedEnvConfig.EnvConfigName == "" { + c.Options.DeprecatedEnvConfig.EnvConfigName = "default" + if envVal, _ := c.Options.EnvLookup.LookupEnv(temporalEnv); envVal != "" { + c.Options.DeprecatedEnvConfig.EnvConfigName = envVal } // Default to --env, prefetched from CLI args for i, arg := range c.Options.Args { if arg == "--env" && i+1 < len(c.Options.Args) { - c.Options.EnvConfigName = c.Options.Args[i+1] + c.Options.DeprecatedEnvConfig.EnvConfigName = c.Options.Args[i+1] } } } // Load env flags - if c.Options.EnvConfigFile != "" { + if c.Options.DeprecatedEnvConfig.EnvConfigFile != "" { var err error - if c.EnvConfigValues, err = readEnvConfigFile(c.Options.EnvConfigFile); err != nil { + c.DeprecatedEnvConfigValues, err = readDeprecatedEnvConfigFile(c.Options.DeprecatedEnvConfig.EnvConfigFile) + if err != nil { return err } } @@ -189,14 +196,6 @@ func (c *CommandContext) BindFlagEnvVar(flag *pflag.Flag, envVar string) { c.FlagsWithEnvVars = append(c.FlagsWithEnvVars, flag) } -func (c *CommandContext) WriteEnvConfigToFile() error { - if c.Options.EnvConfigFile == "" { - return fmt.Errorf("unable to find place for env file (unknown HOME dir)") - } - c.Logger.Info("Writing env file", "file", c.Options.EnvConfigFile) - return writeEnvConfigFile(c.Options.EnvConfigFile, c.EnvConfigValues) -} - func (c *CommandContext) MarshalFriendlyJSONPayloads(m *common.Payloads) (json.RawMessage, error) { if m == nil { return []byte("null"), nil @@ -264,7 +263,7 @@ func (c *CommandContext) populateFlagsFromEnv(flags *pflag.FlagSet) (func(*slog. return } // Env config first, then environ - if v, ok := c.EnvConfigValues[c.Options.EnvConfigName][flag.Name]; ok { + if v, ok := c.DeprecatedEnvConfigValues[c.Options.DeprecatedEnvConfig.EnvConfigName][flag.Name]; ok { if err := flag.Value.Set(v); err != nil { flagErr = fmt.Errorf("failed setting flag %v from config with value %v: %w", flag.Name, v, err) return @@ -272,7 +271,7 @@ func (c *CommandContext) populateFlagsFromEnv(flags *pflag.FlagSet) (func(*slog. flag.Changed = true } if anns := flag.Annotations[flagEnvVarAnnotation]; len(anns) == 1 { - if envVal, ok := c.Options.LookupEnv(anns[0]); ok { + if envVal, _ := c.Options.EnvLookup.LookupEnv(anns[0]); envVal != "" { if err := flag.Value.Set(envVal); err != nil { flagErr = fmt.Errorf("failed setting flag %v with env name %v and value %v: %w", flag.Name, anns[0], envVal, err) @@ -401,13 +400,13 @@ func (c *TemporalCommand) initCommand(cctx *CommandContext) { } cctx.ActuallyRanCommand = true - if cctx.Options.EnvConfigName != "default" { - if _, ok := cctx.EnvConfigValues[cctx.Options.EnvConfigName]; !ok { + if cctx.Options.DeprecatedEnvConfig.EnvConfigName != "default" { + if _, ok := cctx.DeprecatedEnvConfigValues[cctx.Options.DeprecatedEnvConfig.EnvConfigName]; !ok { if _, ok := cmd.Annotations["ignoresMissingEnv"]; !ok { // stfu about help output cmd.SilenceErrors = true cmd.SilenceUsage = true - return fmt.Errorf("environment %q not found", cctx.Options.EnvConfigName) + return fmt.Errorf("environment %q not found", cctx.Options.DeprecatedEnvConfig.EnvConfigName) } } } @@ -431,6 +430,9 @@ func VersionString() string { } func (c *TemporalCommand) preRun(cctx *CommandContext) error { + // Set this command as the root + cctx.RootCommand = c + // Configure logger if not already on context if cctx.Logger == nil { // If level is never, make noop logger @@ -506,43 +508,6 @@ func (c *TemporalCommand) preRun(cctx *CommandContext) error { return nil } -// May be empty result if can't get user home dir -func defaultEnvConfigFile(appName, configName string) string { - // No env file if no $HOME - if dir, err := os.UserHomeDir(); err == nil { - return filepath.Join(dir, ".config", appName, configName+".yaml") - } - return "" -} - -func readEnvConfigFile(file string) (env map[string]map[string]string, err error) { - b, err := os.ReadFile(file) - if os.IsNotExist(err) { - return nil, nil - } else if err != nil { - return nil, fmt.Errorf("failed reading env file: %w", err) - } - var m map[string]map[string]map[string]string - if err := yaml.Unmarshal(b, &m); err != nil { - return nil, fmt.Errorf("failed unmarshalling env YAML: %w", err) - } - return m["env"], nil -} - -func writeEnvConfigFile(file string, env map[string]map[string]string) error { - b, err := yaml.Marshal(map[string]any{"env": env}) - if err != nil { - return fmt.Errorf("failed marshaling YAML: %w", err) - } - // Make parent directories as needed - if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { - return fmt.Errorf("failed making env file parent dirs: %w", err) - } else if err := os.WriteFile(file, b, 0600); err != nil { - return fmt.Errorf("failed writing env file: %w", err) - } - return nil -} - func aliasNormalizer(aliases map[string]string) func(f *pflag.FlagSet, name string) pflag.NormalizedName { return func(f *pflag.FlagSet, name string) pflag.NormalizedName { if actual := aliases[name]; actual != "" { diff --git a/temporalcli/commands.server.go b/temporalcli/commands.server.go index 3edaef80f..5b2ffc253 100644 --- a/temporalcli/commands.server.go +++ b/temporalcli/commands.server.go @@ -179,19 +179,19 @@ func persistentClusterID() string { // If there is not a database file in use, we want a cluster ID to be the same // for every re-run, so we set it as an environment config in a special env // file. We do not error if we can neither read nor write the file. - file := defaultEnvConfigFile("temporalio", "version-info") + file := defaultDeprecatedEnvConfigFile("temporalio", "version-info") if file == "" { // No file, can do nothing here return uuid.NewString() } // Try to get existing first - env, _ := readEnvConfigFile(file) + env, _ := readDeprecatedEnvConfigFile(file) if id := env["default"]["cluster-id"]; id != "" { return id } // Create and try to write id := uuid.NewString() - _ = writeEnvConfigFile(file, map[string]map[string]string{"default": {"cluster-id": id}}) + _ = writeDeprecatedEnvConfigFile(file, map[string]map[string]string{"default": {"cluster-id": id}}) return id } diff --git a/temporalcli/commands.workflow_exec_test.go b/temporalcli/commands.workflow_exec_test.go index 88a0a6d70..28c627bd5 100644 --- a/temporalcli/commands.workflow_exec_test.go +++ b/temporalcli/commands.workflow_exec_test.go @@ -433,12 +433,7 @@ func (s *SharedServerSuite) TestWorkflow_Execute_ClientHeaders() { } func (s *SharedServerSuite) TestWorkflow_Execute_EnvVars() { - s.CommandHarness.Options.LookupEnv = func(key string) (string, bool) { - if key == "TEMPORAL_ADDRESS" { - return s.Address(), true - } - return "", false - } + s.CommandHarness.Options.EnvLookup = EnvLookupMap{"TEMPORAL_ADDRESS": s.Address()} res := s.Execute( "workflow", "execute", "--task-queue", s.Worker().Options.TaskQueue, @@ -492,12 +487,7 @@ func (s *SharedServerSuite) TestWorkflow_Execute_EnvConfig() { s.ContainsOnSameLine(res.Stdout.String(), "Result", `"env-conf-input"`) // And we can specify `env` with a property - s.CommandHarness.Options.LookupEnv = func(key string) (string, bool) { - if key == "TEMPORAL_ENV" { - return "myenv", true - } - return "", false - } + s.CommandHarness.Options.EnvLookup = EnvLookupMap{"TEMPORAL_ENV": "myenv"} res = s.Execute( "workflow", "execute", "--env-file", tmpFile.Name(), diff --git a/temporalcli/commands_test.go b/temporalcli/commands_test.go index 379adb8ef..8b3e7d21b 100644 --- a/temporalcli/commands_test.go +++ b/temporalcli/commands_test.go @@ -163,10 +163,11 @@ func (h *CommandHarness) Execute(args ...string) *CommandResult { // Set args options.Args = args // Disable env if no env file and no --env-file arg - options.DisableEnvConfig = options.EnvConfigFile == "" && !slices.Contains(args, "--env-file") + options.DeprecatedEnvConfig.DisableEnvConfig = + options.DeprecatedEnvConfig.EnvConfigFile == "" && !slices.Contains(args, "--env-file") // Set default env name if disabled, otherwise we'll fail with missing environment - if options.DisableEnvConfig { - options.EnvConfigName = "default" + if options.DeprecatedEnvConfig.DisableEnvConfig { + options.DeprecatedEnvConfig.EnvConfigName = "default" } // Capture error options.Fail = func(err error) { @@ -191,6 +192,21 @@ func (h *CommandHarness) Execute(args ...string) *CommandResult { return res } +type EnvLookupMap map[string]string + +func (e EnvLookupMap) Environ() []string { + ret := make([]string, 0, len(e)) + for k := range e { + ret = append(ret, k) + } + return ret +} + +func (e EnvLookupMap) LookupEnv(key string) (string, bool) { + v, ok := e[key] + return v, ok +} + // Run shared server suite func TestSharedServerSuite(t *testing.T) { suite.Run(t, new(SharedServerSuite)) diff --git a/temporalcli/commandsgen/code.go b/temporalcli/commandsgen/code.go index 5c10057b5..e51296d46 100644 --- a/temporalcli/commandsgen/code.go +++ b/temporalcli/commandsgen/code.go @@ -208,6 +208,9 @@ func (c *Command) writeCode(w *codeWriter) error { w.writeLinef("s.Command.Annotations = make(map[string]string)") w.writeLinef("s.Command.Annotations[\"ignoresMissingEnv\"] = \"true\"") } + if c.Deprecated != "" { + w.writeLinef("s.Command.Deprecated = %q", c.Deprecated) + } // Add subcommands for _, subCommand := range subCommands { w.writeLinef("s.Command.AddCommand(&New%v(cctx, &s).Command)", subCommand.structName()) @@ -408,6 +411,10 @@ func (o *Option) writeFlagBuilding(selfVar, flagVar string, w *codeWriter) error for _, alias := range o.Aliases { desc += fmt.Sprintf(` Aliased as "--%v".`, alias) } + // If experimental, make obvious + if o.Experimental { + desc += " EXPERIMENTAL." + } if setDefault != "" { // set default before calling Var so that it stores thedefault value into the flag @@ -426,5 +433,8 @@ func (o *Option) writeFlagBuilding(selfVar, flagVar string, w *codeWriter) error if o.Env != "" { w.writeLinef("cctx.BindFlagEnvVar(%v.Lookup(%q), %q)", flagVar, o.Name, o.Env) } + if o.Deprecated != "" { + w.writeLinef("_ = %v.MarkDeprecated(%q, %q)", flagVar, o.Name, o.Deprecated) + } return nil } diff --git a/temporalcli/commandsgen/commands.yml b/temporalcli/commandsgen/commands.yml index 67d2d3af9..ce8a264ba 100644 --- a/temporalcli/commandsgen/commands.yml +++ b/temporalcli/commandsgen/commands.yml @@ -101,6 +101,8 @@ # required: Whether the option is required. (bool) # short: The single letter short version of name (i.e. a for address). (string) # env: Binds the environment variable to this flag. (string) +# implied-env: Documents the environment variable as bound to the flag, +# but does actually bind it. # default: The default value. No default means zero value of the type. (string) # enum-values: A list of possible values for the string-enum type. (string[]) # aliases: A list of aliases for the option. (string[]) @@ -143,11 +145,44 @@ commands: description: Active environment name (`ENV`). default: default env: TEMPORAL_ENV + # TODO(cretz): Deprecate when `config` GA + # deprecated: | + # Use `profile` instead. If an env file is present, it will take + # precedent over `config` file or config environment variables. - name: env-file type: string description: | Path to environment settings file. Defaults to `$HOME/.config/temporalio/temporal.yaml`. + # TODO(cretz): Deprecate when `config` GA + # deprecated: | + # Use `config-file` instead. If an env file is present, it will take + # precedent over `config` file or config environment variables. + - name: config-file + type: string + description: | + File path to read TOML config from, defaults to + `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is defined + as `$HOME/.config` on Unix, "$HOME/Library/Application Support" on + macOS, and %AppData% on Windows. + experimental: true + implied-env: TEMPORAL_CONFIG_FILE + - name: profile + type: string + description: Profile to use for config file. + experimental: true + implied-env: TEMPORAL_PROFILE + - name: disable-config-file + type: bool + description: | + If set, disables loading environment config from config file. + experimental: true + - name: disable-config-env + type: bool + description: | + If set, disables loading environment config from environment + variables. + experimental: true - name: log-level type: string-enum enum-values: @@ -647,6 +682,119 @@ commands: description: Reason for terminating the batch job. required: true + - name: temporal config + summary: Manage config files (EXPERIMENTAL) + description: | + Config files are TOML files that contain profiles, with each profile + containing configuration for connecting to Temporal. + + ``` + temporal config set \ + --prop address \ + --value us-west-2.aws.api.temporal.io:7233 + ``` + + The default config file is at `$CONFIG_PATH/temporal/temporal.toml` where + `$CONFIG_PATH` is defined as `$HOME/.config` on Unix, + `$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows. + This can be overridden with the `TEMPORAL_CONFIG_FILE` environment + variable or `--config-file`. + + The default profile is `default`. This can be overridden with the + `TEMPORAL_PROFILE` environment variable or `--profile`. + docs: + description-header: >- + Temporal CLI 'config' commands allow the getting, setting, deleting, and + listing of configuration properties for connecting to Temporal. + keywords: + - cli reference + - command-line-interface-cli + - configuration + - config + - config delete + - config get + - config list + - config set + - environment + - temporal cli + + - name: temporal config delete + summary: | + Delete a config file property or an entire config profile (EXPERIMENTAL) + description: | + Remove a full profile entirely _or_ remove a property within a profile. + When deleting an entire profile, the `--profile` must be set explicitly. + + ``` + temporal env delete \ + --profile my-profile + ``` + + or + + ``` + temporal env delete \ + --prop tls.client_cert_path + ``` + options: + - name: prop + short: p + type: string + description: | + Specific property to delete. If unset, deletes entire profile. + + - name: temporal config get + summary: Show config file properties (EXPERIMENTAL) + description: | + Display specific properties or the entire profile. + + ``` + temporal config get \ + --prop address + ``` + + or + + ``` + temporal config get + ``` + options: + - name: prop + short: p + type: string + description: Specific property to get. + + - name: temporal config list + summary: Show config file profiles (EXPERIMENTAL) + description: | + List profile names in the config file. + + ``` + temporal config list + ``` + + - name: temporal config set + summary: Set config file properties (EXPERIMENTAL) + description: | + Assign a value to a property and store it in the config file: + + ``` + temporal config set \ + --prop address \ + --value us-west-2.aws.api.temporal.io:7233 + ``` + options: + - name: prop + short: p + type: string + description: Property name. + required: true + - name: value + short: v + type: string + description: Property value. + required: true + - name: temporal env summary: Manage environments description: | @@ -667,6 +815,8 @@ commands: Temporal CLI checks for an `--env` option first, then checks for the `TEMPORAL_ENV` environment variable. If neither is set, the CLI uses the "default" environment. + # TODO(cretz): Deprecate when `config` GA + # deprecated: Use `config` subcommands instead. docs: description-header: >- Temporal CLI 'env' commands allow the configuration, setting, deleting, @@ -703,6 +853,8 @@ commands: --env prod \ --key tls-key-path ``` + # TODO(cretz): Deprecate when `config` GA + # deprecated: Use `config` subcommands instead. maximum-args: 1 options: - name: key @@ -731,6 +883,8 @@ commands: If you don't specify an environment (with `--env` or by setting the `TEMPORAL_ENV` variable), this command lists properties of the "default" environment. + # TODO(cretz): Deprecate when `config` GA + # deprecated: Use `config` subcommands instead. maximum-args: 1 options: - name: key @@ -743,6 +897,8 @@ commands: description: | List the environments you have set up on your local computer. Environments are stored in "$HOME/.config/temporalio/temporal.yaml". + # TODO(cretz): Deprecate when `config` GA + # deprecated: Use `config` subcommands instead. ignores-missing-env: true - name: temporal env set @@ -764,6 +920,8 @@ commands: Storing keys with CLI option names lets the CLI automatically set those options for you. This reduces effort and helps avoid typos when issuing commands. + # TODO(cretz): Deprecate when `config` GA + # deprecated: Use `config` subcommands instead. maximum-args: 2 ignores-missing-env: true options: @@ -2830,10 +2988,8 @@ commands: required: true - name: reapply-type type: string-enum - description: | - Types of events to re-apply after reset point. - Deprecated. - Use --reapply-exclude instead. + description: Types of events to re-apply after reset point. + deprecated: Use --reapply-exclude instead. enum-values: - All - Signal @@ -3177,81 +3333,85 @@ option-sets: type: string description: Temporal Service gRPC endpoint. default: 127.0.0.1:7233 - env: TEMPORAL_ADDRESS + implied-env: TEMPORAL_ADDRESS - name: namespace short: n type: string description: Temporal Service Namespace. default: default - env: TEMPORAL_NAMESPACE + implied-env: TEMPORAL_NAMESPACE - name: api-key type: string description: API key for request. - env: TEMPORAL_API_KEY + implied-env: TEMPORAL_API_KEY - name: grpc-meta type: string[] description: | HTTP headers for requests. format as a `KEY=VALUE` pair May be passed multiple times to set multiple headers. + Can also be made available via environment variable as + `TEMPORAL_GRPC_META_[name]`. - name: tls type: bool description: | - Enable base TLS encryption. - Does not have additional options like mTLS or client certs. - env: TEMPORAL_TLS + Enable base TLS encryption. Does not have additional options like mTLS + or client certs. Unlike some other options, if this is present and set + explicitly to false, it can still be overridden by config file or + environment variables. + implied-env: TEMPORAL_TLS - name: tls-cert-path type: string description: | Path to x509 certificate. Can't be used with --tls-cert-data. - env: TEMPORAL_TLS_CERT + implied-env: TEMPORAL_TLS_CLIENT_CERT_PATH - name: tls-cert-data type: string description: | Data for x509 certificate. Can't be used with --tls-cert-path. - env: TEMPORAL_TLS_CERT_DATA + implied-env: TEMPORAL_TLS_CLIENT_CERT_DATA - name: tls-key-path type: string description: | Path to x509 private key. Can't be used with --tls-key-data. - env: TEMPORAL_TLS_KEY + implied-env: TEMPORAL_TLS_CLIENT_KEY_PATH - name: tls-key-data type: string description: | Private certificate key data. Can't be used with --tls-key-path. - env: TEMPORAL_TLS_KEY_DATA + implied-env: TEMPORAL_TLS_CLIENT_KEY_DATA - name: tls-ca-path type: string description: | Path to server CA certificate. Can't be used with --tls-ca-data. - env: TEMPORAL_TLS_CA + implied-env: TEMPORAL_TLS_SERVER_CA_CERT_PATH - name: tls-ca-data type: string description: | Data for server CA certificate. Can't be used with --tls-ca-path. - env: TEMPORAL_TLS_CA_DATA + implied-env: TEMPORAL_TLS_SERVER_CA_CERT_DATA - name: tls-disable-host-verification type: bool description: Disable TLS host-name verification. - env: TEMPORAL_TLS_DISABLE_HOST_VERIFICATION + implied-env: TEMPORAL_TLS_DISABLE_HOST_VERIFICATION - name: tls-server-name type: string description: Override target TLS server name. - env: TEMPORAL_TLS_SERVER_NAME + implied-env: TEMPORAL_TLS_SERVER_NAME - name: codec-endpoint type: string description: Remote Codec Server endpoint. - env: TEMPORAL_CODEC_ENDPOINT + implied-env: TEMPORAL_CODEC_ENDPOINT - name: codec-auth type: string description: Authorization header for Codec Server requests. - env: TEMPORAL_CODEC_AUTH + implied-env: TEMPORAL_CODEC_AUTH - name: overlap-policy options: @@ -3443,10 +3603,8 @@ option-sets: options: - name: cron type: string - description: | - Cron schedule for the Workflow. - Deprecated. - Use Schedules instead. + description: Cron schedule for the Workflow. + deprecated: Use Schedules instead. - name: fail-existing type: bool description: Fail if the Workflow already exists. diff --git a/temporalcli/commandsgen/parse.go b/temporalcli/commandsgen/parse.go index 25cebfa86..62c163d48 100644 --- a/temporalcli/commandsgen/parse.go +++ b/temporalcli/commandsgen/parse.go @@ -23,9 +23,11 @@ type ( Name string `yaml:"name"` Type string `yaml:"type"` Description string `yaml:"description"` + Deprecated string `yaml:"deprecated"` Short string `yaml:"short,omitempty"` Default string `yaml:"default,omitempty"` Env string `yaml:"env,omitempty"` + ImpliedEnv string `yaml:"implied-env,omitempty"` Required bool `yaml:"required,omitempty"` Aliases []string `yaml:"aliases,omitempty"` EnumValues []string `yaml:"enum-values,omitempty"` @@ -41,6 +43,7 @@ type ( Description string `yaml:"description"` DescriptionPlain string DescriptionHighlighted string + Deprecated string `yaml:"deprecated"` HasInit bool `yaml:"has-init"` ExactArgs int `yaml:"exact-args"` MaximumArgs int `yaml:"maximum-args"` diff --git a/temporalcli/internal/printer/printer.go b/temporalcli/internal/printer/printer.go index 6c3fdac11..cfe5f1e20 100644 --- a/temporalcli/internal/printer/printer.go +++ b/temporalcli/internal/printer/printer.go @@ -1,6 +1,7 @@ package printer import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -414,6 +415,9 @@ func (p *Printer) textVal(v any) string { return fmt.Sprintf("", err) } return string(b) + } else if ref.Kind() == reflect.Slice && ref.Type().Elem().Kind() == reflect.Uint8 { + b, _ := ref.Interface().([]byte) + return "bytes(" + base64.StdEncoding.EncodeToString(b) + ")" } else if ref.Kind() == reflect.Slice { // We don't want to reimplement all of fmt.Sprintf, but expanding one level of // slice helps format lists more consistently. From 67b32d47c0a93e239095c8a9d8e83976efc4727b Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Wed, 26 Mar 2025 13:21:56 -0500 Subject: [PATCH 2/8] Separated delete-profile into its own command --- go.mod | 2 +- go.sum | 4 +-- temporalcli/commands.config.go | 30 ++++++++++++++------- temporalcli/commands.config_test.go | 4 +-- temporalcli/commands.gen.go | 39 +++++++++++++++++++++++----- temporalcli/commandsgen/commands.yml | 26 +++++++++++-------- 6 files changed, 73 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index ec06348a6..0b6c0e9d6 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 github.com/mattn/go-isatty v0.0.20 - github.com/nexus-rpc/sdk-go v0.2.0 + github.com/nexus-rpc/sdk-go v0.3.0 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index dcfc1f16f..4e2ab7612 100644 --- a/go.sum +++ b/go.sum @@ -241,8 +241,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/nexus-rpc/sdk-go v0.2.0 h1:NKMkfTTQDEkbnP46/oB7cV7Ml25Wk+9w7lOyeYJQLAc= -github.com/nexus-rpc/sdk-go v0.2.0/go.mod h1:TpfkM2Cw0Rlk9drGkoiSMpFqflKTiQLWUNyKJjF8mKQ= +github.com/nexus-rpc/sdk-go v0.3.0 h1:Y3B0kLYbMhd4C2u00kcYajvmOrfozEtTV/nHSnV57jA= +github.com/nexus-rpc/sdk-go v0.3.0/go.mod h1:TpfkM2Cw0Rlk9drGkoiSMpFqflKTiQLWUNyKJjF8mKQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= diff --git a/temporalcli/commands.config.go b/temporalcli/commands.config.go index 7ce1e4b78..a4028ff46 100644 --- a/temporalcli/commands.config.go +++ b/temporalcli/commands.config.go @@ -20,16 +20,7 @@ func (c *TemporalConfigDeleteCommand) run(cctx *CommandContext, _ []string) erro if err != nil { return err } - // If it's a specific prop, unset it, otherwise just remove the profile - if c.Prop == "" { - // To make extra sure they meant to do this, we require the profile name - // as an explicit CLI arg. This prevents accidentally deleting the - // "default" profile. - if cctx.RootCommand.Profile == "" { - return fmt.Errorf("to delete an entire profile, --profile must be provided explicitly") - } - delete(conf.Profiles, profileName) - } else if strings.HasPrefix(c.Prop, "grpc_meta.") { + if strings.HasPrefix(c.Prop, "grpc_meta.") { key := strings.TrimPrefix(c.Prop, "grpc_meta.") if _, ok := confProfile.GRPCMeta[key]; !ok { return fmt.Errorf("gRPC meta key %q not found", key) @@ -47,6 +38,25 @@ func (c *TemporalConfigDeleteCommand) run(cctx *CommandContext, _ []string) erro return writeEnvConfigFile(cctx, conf) } +func (c *TemporalConfigDeleteProfileCommand) run(cctx *CommandContext, _ []string) error { + // Load config + profileName := envConfigProfileName(cctx) + conf, _, err := loadEnvConfigProfile(cctx, profileName, true) + if err != nil { + return err + } + // To make extra sure they meant to do this, we require the profile name + // as an explicit CLI arg. This prevents accidentally deleting the + // "default" profile. + if cctx.RootCommand.Profile == "" { + return fmt.Errorf("to delete an entire profile, --profile must be provided explicitly") + } + delete(conf.Profiles, profileName) + + // Save + return writeEnvConfigFile(cctx, conf) +} + func (c *TemporalConfigGetCommand) run(cctx *CommandContext, _ []string) error { // Load config profile profileName := envConfigProfileName(cctx) diff --git a/temporalcli/commands.config_test.go b/temporalcli/commands.config_test.go index 40bd5b4af..dbf6ed4b4 100644 --- a/temporalcli/commands.config_test.go +++ b/temporalcli/commands.config_test.go @@ -237,7 +237,7 @@ namespace = "my-namespace" h.NotContains(res.Stdout.String(), "my-namespace") // Delete entire profile - res = h.Execute("config", "delete", "--profile", "foo") + res = h.Execute("config", "delete-profile", "--profile", "foo") h.NoError(res.Err) res = h.Execute("config", "get") h.ErrorContains(res.Err, `profile "foo" not found`) @@ -273,7 +273,7 @@ address = "my-address-bar"`)) h.Contains(res.Stdout.String(), `"bar"`) // Now delete and try again - res = h.Execute("config", "delete", "--profile", "foo") + res = h.Execute("config", "delete-profile", "--profile", "foo") h.NoError(res.Err) res = h.Execute("config", "list") h.NoError(res.Err) diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index b57def06e..e4e4ec9ab 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -700,12 +700,13 @@ func NewTemporalConfigCommand(cctx *CommandContext, parent *TemporalCommand) *Te s.Command.Use = "config" s.Command.Short = "Manage config files (EXPERIMENTAL)" if hasHighlighting { - s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file is at \x1b[1m$CONFIG_PATH/temporal/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m." + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file path is \x1b[1m$CONFIG_PATH/temporal/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m." } else { - s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file is at `$CONFIG_PATH/temporal/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`." + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file path is `$CONFIG_PATH/temporal/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`." } s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewTemporalConfigDeleteCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalConfigDeleteProfileCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalConfigGetCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalConfigListCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalConfigSetCommand(cctx, &s).Command) @@ -723,14 +724,40 @@ func NewTemporalConfigDeleteCommand(cctx *CommandContext, parent *TemporalConfig s.Parent = parent s.Command.DisableFlagsInUseLine = true s.Command.Use = "delete [flags]" - s.Command.Short = "Delete a config file property or an entire config profile (EXPERIMENTAL)\n" + s.Command.Short = "Delete a config file property (EXPERIMENTAL)\n" if hasHighlighting { - s.Command.Long = "Remove a full profile entirely _or_ remove a property within a profile.\nWhen deleting an entire profile, the \x1b[1m--profile\x1b[0m must be set explicitly.\n\n\x1b[1mtemporal env delete \\\n --profile my-profile\x1b[0m\n\nor\n\n\x1b[1mtemporal env delete \\\n --prop tls.client_cert_path\x1b[0m" + s.Command.Long = "Remove a property within a profile.\n\n\x1b[1mtemporal env delete \\\n --prop tls.client_cert_path\x1b[0m" } else { - s.Command.Long = "Remove a full profile entirely _or_ remove a property within a profile.\nWhen deleting an entire profile, the `--profile` must be set explicitly.\n\n```\ntemporal env delete \\\n --profile my-profile\n```\n\nor\n\n```\ntemporal env delete \\\n --prop tls.client_cert_path\n```" + s.Command.Long = "Remove a property within a profile.\n\n```\ntemporal env delete \\\n --prop tls.client_cert_path\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to delete. If unset, deletes entire profile. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "prop") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type TemporalConfigDeleteProfileCommand struct { + Parent *TemporalConfigCommand + Command cobra.Command +} + +func NewTemporalConfigDeleteProfileCommand(cctx *CommandContext, parent *TemporalConfigCommand) *TemporalConfigDeleteProfileCommand { + var s TemporalConfigDeleteProfileCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "delete-profile [flags]" + s.Command.Short = "Delete an entire config profile (EXPERIMENTAL)\n" + if hasHighlighting { + s.Command.Long = "Remove a full profile entirely. The \x1b[1m--profile\x1b[0m must be set explicitly.\n\n\x1b[1mtemporal env delete-profile \\\n --profile my-profile\x1b[0m" + } else { + s.Command.Long = "Remove a full profile entirely. The `--profile` must be set explicitly.\n\n```\ntemporal env delete-profile \\\n --profile my-profile\n```" } s.Command.Args = cobra.NoArgs - s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to delete. If unset, deletes entire profile.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) diff --git a/temporalcli/commandsgen/commands.yml b/temporalcli/commandsgen/commands.yml index ce8a264ba..d99320560 100644 --- a/temporalcli/commandsgen/commands.yml +++ b/temporalcli/commandsgen/commands.yml @@ -694,7 +694,7 @@ commands: --value us-west-2.aws.api.temporal.io:7233 ``` - The default config file is at `$CONFIG_PATH/temporal/temporal.toml` where + The default config file path is `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is defined as `$HOME/.config` on Unix, `$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows. This can be overridden with the `TEMPORAL_CONFIG_FILE` environment @@ -720,17 +720,9 @@ commands: - name: temporal config delete summary: | - Delete a config file property or an entire config profile (EXPERIMENTAL) + Delete a config file property (EXPERIMENTAL) description: | - Remove a full profile entirely _or_ remove a property within a profile. - When deleting an entire profile, the `--profile` must be set explicitly. - - ``` - temporal env delete \ - --profile my-profile - ``` - - or + Remove a property within a profile. ``` temporal env delete \ @@ -742,6 +734,18 @@ commands: type: string description: | Specific property to delete. If unset, deletes entire profile. + required: true + + - name: temporal config delete-profile + summary: | + Delete an entire config profile (EXPERIMENTAL) + description: | + Remove a full profile entirely. The `--profile` must be set explicitly. + + ``` + temporal env delete-profile \ + --profile my-profile + ``` - name: temporal config get summary: Show config file properties (EXPERIMENTAL) From 15f42ae8cbf48c4a332c3c699e0cbcc808521484 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Tue, 1 Apr 2025 16:40:34 -0500 Subject: [PATCH 3/8] Fix for only overriding some envconfig options if explicitly set --- temporalcli/client.go | 13 ++++++++++--- temporalcli/commands.env_test.go | 8 -------- temporalcli/commands.go | 7 +++++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/temporalcli/client.go b/temporalcli/client.go index eac65e737..3612b8146 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -74,11 +74,18 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) } } - // Override some values in client config profile that come from CLI args - if c.Address != "" { + // Override some values in client config profile that come from CLI args. Some + // flags, like address and namespace, have CLI defaults, but we don't want to + // override the profile version unless it was _explicitly_ set. + var addressExplicitlySet, namespaceExplicitlySet bool + if cctx.CurrentCommand != nil { + addressExplicitlySet = cctx.CurrentCommand.Flags().Changed("address") + namespaceExplicitlySet = cctx.CurrentCommand.Flags().Changed("namespace") + } + if addressExplicitlySet { clientProfile.Address = c.Address } - if c.Namespace != "" { + if namespaceExplicitlySet { clientProfile.Namespace = c.Namespace } if c.ApiKey != "" { diff --git a/temporalcli/commands.env_test.go b/temporalcli/commands.env_test.go index 504ebc1da..cdd1157a9 100644 --- a/temporalcli/commands.env_test.go +++ b/temporalcli/commands.env_test.go @@ -69,14 +69,6 @@ func TestEnv_Simple(t *testing.T) { res = h.Execute("env", "list") h.NoError(res.Err) h.NotContains(res.Stdout.String(), "myenv2") - - // Ensure env var overrides env file - res = h.Execute("env", "set", "--env", "myenv1", "-k", "address", "-v", "something:1234") - h.NoError(res.Err) - h.NoError(os.Setenv("TEMPORAL_ADDRESS", "overridden:1235")) - defer os.Unsetenv("TEMPORAL_ADDRESS") - res = h.Execute("workflow", "list", "--env", "myenv1") - h.Contains(res.Stderr.String(), "Env var overrode --env setting") } func TestEnv_InputValidation(t *testing.T) { diff --git a/temporalcli/commands.go b/temporalcli/commands.go index fef9f9036..6df20f923 100644 --- a/temporalcli/commands.go +++ b/temporalcli/commands.go @@ -55,8 +55,9 @@ type CommandContext struct { // that cobra does not properly exit nonzero if an unknown command/subcommand is given. ActuallyRanCommand bool - // Root command only set inside of pre-run - RootCommand *TemporalCommand + // Root/current command only set inside of pre-run + RootCommand *TemporalCommand + CurrentCommand *cobra.Command } type CommandOptions struct { @@ -379,6 +380,8 @@ func (c *TemporalCommand) initCommand(cctx *CommandContext) { // must unset in post-run origNoColor := color.NoColor c.Command.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // Set command + cctx.CurrentCommand = cmd // Populate environ. We will make the error return here which will cause // usage to be printed. logCalls, err := cctx.populateFlagsFromEnv(cmd.Flags()) From fb9b2e1355c3fe6191b5b08620006ba8a06f5160 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Thu, 3 Apr 2025 09:46:22 -0500 Subject: [PATCH 4/8] BREAKING: Made --api-key enable TLS by default --- .github/workflows/ci.yaml | 19 ++++++++++++++++++- temporalcli/client.go | 11 ++++------- temporalcli/commands.config_test.go | 21 +++++++++++++++++++++ temporalcli/commands.gen.go | 2 +- temporalcli/commandsgen/commands.yml | 5 ++--- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ee1a773a1..49449298d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,7 +56,7 @@ jobs: go run ./temporalcli/internal/cmd/gen-commands git diff --exit-code - - name: Test cloud + - name: Test cloud mTLS if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} env: TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 @@ -70,3 +70,20 @@ jobs: printf '%s\n' "$TEMPORAL_TLS_CERT_CONTENT" >> client.crt printf '%s\n' "$TEMPORAL_TLS_KEY_CONTENT" >> client.key go run ./cmd/temporal workflow list --limit 2 + + - name: Test cloud API key env var + if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} + env: + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + shell: bash + run: go run ./cmd/temporal workflow list --limit 2 + + - name: Test cloud API key arg + if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} + env: + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + shell: bash + run: go run ./cmd/temporal workflow list --limit 2 --api-key ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} diff --git a/temporalcli/client.go b/temporalcli/client.go index 3612b8146..5ba68a340 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -139,13 +139,10 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) clientProfile.TLS.DisableHostVerification = c.TlsDisableHostVerification } } - // In the past, the presence of API key CLI arg did not imply TLS like it - // does with envconfig. Therefore if there is a user-provided API key and - // TLS is not present, explicitly disable it so API key presence doesn't - // enable it in ToClientOptions below. - // TODO(cretz): Or do we want to break compatibility to have TLS defaulted - // for all API keys? - if c.ApiKey != "" && clientProfile.TLS == nil { + + // If TLS is explicitly disabled, we turn it off. Otherwise it may be + // implicitly enabled if API key or any other TLS setting is set. + if cctx.CurrentCommand.Flags().Changed("tls") && !c.Tls { clientProfile.TLS = &envconfig.ClientConfigTLS{Disabled: true} } diff --git a/temporalcli/commands.config_test.go b/temporalcli/commands.config_test.go index dbf6ed4b4..5cc89ec86 100644 --- a/temporalcli/commands.config_test.go +++ b/temporalcli/commands.config_test.go @@ -372,3 +372,24 @@ func TestConfig_Set(t *testing.T) { }, all) } + +func (s *SharedServerSuite) TestAPIKey_DefaultsTLS() { + // A workflow list with an API key should fail because TLS is enabled by + // default when --api-key is present + res := s.Execute( + "workflow", "count", + "--address", s.Address(), + "--api-key", "does-not-matter", + ) + s.ErrorContains(res.Err, "tls") + + // But it should succeed with TLS explicitly disabled + res = s.Execute( + "workflow", "count", + "--address", s.Address(), + "--api-key", "does-not-matter", + "--tls=false", + ) + s.NoError(res.Err) + s.Contains(res.Stdout.String(), "Total") +} diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index 29190faab..317f55724 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -40,7 +40,7 @@ func (v *ClientOptions) buildFlags(cctx *CommandContext, f *pflag.FlagSet) { f.StringVarP(&v.Namespace, "namespace", "n", "default", "Temporal Service Namespace.") f.StringVar(&v.ApiKey, "api-key", "", "API key for request.") f.StringArrayVar(&v.GrpcMeta, "grpc-meta", nil, "HTTP headers for requests. Format as a `KEY=VALUE` pair. May be passed multiple times to set multiple headers. Can also be made available via environment variable as `TEMPORAL_GRPC_META_[name]`.") - f.BoolVar(&v.Tls, "tls", false, "Enable base TLS encryption. Does not have additional options like mTLS or client certs. Unlike some other options, if this is present and set explicitly to false, it can still be overridden by config file or environment variables.") + f.BoolVar(&v.Tls, "tls", false, "Enable base TLS encryption. Does not have additional options like mTLS or client certs. This is defaulted to true if api-key or any other TLS options are present. Use --tls=false to explicitly disable.") f.StringVar(&v.TlsCertPath, "tls-cert-path", "", "Path to x509 certificate. Can't be used with --tls-cert-data.") f.StringVar(&v.TlsCertData, "tls-cert-data", "", "Data for x509 certificate. Can't be used with --tls-cert-path.") f.StringVar(&v.TlsKeyPath, "tls-key-path", "", "Path to x509 private key. Can't be used with --tls-key-data.") diff --git a/temporalcli/commandsgen/commands.yml b/temporalcli/commandsgen/commands.yml index 83ef0f670..f63ad6166 100644 --- a/temporalcli/commandsgen/commands.yml +++ b/temporalcli/commandsgen/commands.yml @@ -4026,9 +4026,8 @@ option-sets: type: bool description: | Enable base TLS encryption. Does not have additional options like mTLS - or client certs. Unlike some other options, if this is present and set - explicitly to false, it can still be overridden by config file or - environment variables. + or client certs. This is defaulted to true if api-key or any other TLS + options are present. Use --tls=false to explicitly disable. implied-env: TEMPORAL_TLS - name: tls-cert-path type: string From 2f11d52b34f27f652e79ce022c117d6ab933dd3f Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Thu, 3 Apr 2025 09:52:59 -0500 Subject: [PATCH 5/8] CI fix --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49449298d..bf2750297 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -74,7 +74,7 @@ jobs: - name: Test cloud API key env var if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} env: - TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_ADDRESS: us-west-2.aws.api.temporal.io:7233 TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} shell: bash @@ -83,7 +83,7 @@ jobs: - name: Test cloud API key arg if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} env: - TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_ADDRESS: us-west-2.aws.api.temporal.io:7233 TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} shell: bash run: go run ./cmd/temporal workflow list --limit 2 --api-key ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} From bb925004672f66b1afd9cbdf456d3030b0215e6e Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Fri, 18 Apr 2025 13:05:51 -0500 Subject: [PATCH 6/8] Fix issue with derived namespace not being accurate --- temporalcli/client.go | 9 +++++++++ temporalcli/commands.gen.go | 4 ++-- temporalcli/commandsgen/commands.yml | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/temporalcli/client.go b/temporalcli/client.go index 5ba68a340..4b41bfc4a 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -17,6 +17,11 @@ import ( "google.golang.org/grpc/metadata" ) +// Dial a client. +// +// Note, this call may mutate the receiver [ClientOptions.Namespace] since it is +// so often used by callers after this call to know the currently configured +// namespace. func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) { if cctx.RootCommand == nil { return nil, fmt.Errorf("root command unexpectedly missing when dialing client") @@ -87,6 +92,10 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) } if namespaceExplicitlySet { clientProfile.Namespace = c.Namespace + } else if clientProfile.Namespace != "" { + // Since this namespace value is used by many commands after this call, + // we are mutating it to be the derived one + c.Namespace = clientProfile.Namespace } if c.ApiKey != "" { clientProfile.APIKey = c.ApiKey diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index 317f55724..d6130cc25 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -746,9 +746,9 @@ func NewTemporalConfigCommand(cctx *CommandContext, parent *TemporalCommand) *Te s.Command.Use = "config" s.Command.Short = "Manage config files (EXPERIMENTAL)" if hasHighlighting { - s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file path is \x1b[1m$CONFIG_PATH/temporal/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m." + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file path is \x1b[1m$CONFIG_PATH/temporalio/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m." } else { - s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file path is `$CONFIG_PATH/temporal/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`." + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal. \n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file path is `$CONFIG_PATH/temporalio/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`." } s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewTemporalConfigDeleteCommand(cctx, &s).Command) diff --git a/temporalcli/commandsgen/commands.yml b/temporalcli/commandsgen/commands.yml index f63ad6166..a74d334d9 100644 --- a/temporalcli/commandsgen/commands.yml +++ b/temporalcli/commandsgen/commands.yml @@ -694,7 +694,7 @@ commands: --value us-west-2.aws.api.temporal.io:7233 ``` - The default config file path is `$CONFIG_PATH/temporal/temporal.toml` where + The default config file path is `$CONFIG_PATH/temporalio/temporal.toml` where `$CONFIG_PATH` is defined as `$HOME/.config` on Unix, `$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows. This can be overridden with the `TEMPORAL_CONFIG_FILE` environment From 8d6fffe1f283e6c4078264d5ac0bb0025473002c Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Fri, 18 Apr 2025 13:25:46 -0500 Subject: [PATCH 7/8] Add tags --- temporalcli/commandsgen/commands.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/temporalcli/commandsgen/commands.yml b/temporalcli/commandsgen/commands.yml index 84c4f39b7..28a47d6b3 100644 --- a/temporalcli/commandsgen/commands.yml +++ b/temporalcli/commandsgen/commands.yml @@ -722,6 +722,8 @@ commands: - config set - environment - temporal cli + tags: + - Temporal CLI - name: temporal config delete summary: | From 85435bc0b5dad3a9b4d62d74c99b19d3630da7dc Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Mon, 21 Apr 2025 12:14:04 -0500 Subject: [PATCH 8/8] Minor typo --- temporalcli/commandsgen/commands.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalcli/commandsgen/commands.yml b/temporalcli/commandsgen/commands.yml index 28a47d6b3..2a551a5f7 100644 --- a/temporalcli/commandsgen/commands.yml +++ b/temporalcli/commandsgen/commands.yml @@ -102,7 +102,7 @@ # short: The single letter short version of name (i.e. a for address). (string) # env: Binds the environment variable to this flag. (string) # implied-env: Documents the environment variable as bound to the flag, -# but does actually bind it. +# but doesn't actually bind it. # default: The default value. No default means zero value of the type. (string) # enum-values: A list of possible values for the string-enum type. (string[]) # aliases: A list of aliases for the option. (string[])