From 94101b65e287ecee2e64c7b5769d1f5c4a3d857e Mon Sep 17 00:00:00 2001 From: Yimin Chen Date: Sat, 17 Feb 2024 16:12:36 -0800 Subject: [PATCH] Add operator search-attribute command --- .gitignore | 7 + CONTRIBUTING.md | 4 + temporalcli/commands.gen.go | 105 +++++++++++++ .../commands.operator_search_attribute.go | 141 ++++++++++++++++++ ...commands.operator_search_attribute_test.go | 100 +++++++++++++ temporalcli/commandsmd/commands.md | 26 ++++ 6 files changed, 383 insertions(+) create mode 100644 .gitignore create mode 100644 temporalcli/commands.operator_search_attribute.go create mode 100644 temporalcli/commands.operator_search_attribute_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e4f812ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Binaries for programs and plugins +/temporal +/temporal.exe + +# Used by IDE +/.idea +/.vscode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 018d18f8..873d9b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,10 @@ Uses normal `go test`, e.g.: See other tests for how to leverage things like the command harness and dev server suite. +Example to run a single test case: + + go test ./... -run TestSharedServerSuite/TestOperator_SearchAttribute + ## Adding/updating commands First, update [commands.md](temporalcli/commandsmd/commands.md) following the rules in that file. Then to regenerate the diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index 917811a0..b237686e 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -281,6 +281,7 @@ func NewTemporalOperatorCommand(cctx *CommandContext, parent *TemporalCommand) * } s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewTemporalOperatorClusterCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalOperatorSearchAttributeCommand(cctx, &s).Command) s.ClientOptions.buildFlags(cctx, s.Command.PersistentFlags()) return &s } @@ -472,6 +473,110 @@ func NewTemporalOperatorClusterUpsertCommand(cctx *CommandContext, parent *Tempo return &s } +type TemporalOperatorSearchAttributeCommand struct { + Parent *TemporalOperatorCommand + Command cobra.Command +} + +func NewTemporalOperatorSearchAttributeCommand(cctx *CommandContext, parent *TemporalOperatorCommand) *TemporalOperatorSearchAttributeCommand { + var s TemporalOperatorSearchAttributeCommand + s.Parent = parent + s.Command.Use = "search-attribute" + s.Command.Short = "Operations applying to Search Attributes" + s.Command.Long = "Search Attribute commands enable operations for the creation, listing, and removal of Search Attributes." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewTemporalOperatorSearchAttributeCreateCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalOperatorSearchAttributeListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalOperatorSearchAttributeRemoveCommand(cctx, &s).Command) + return &s +} + +type TemporalOperatorSearchAttributeCreateCommand struct { + Parent *TemporalOperatorSearchAttributeCommand + Command cobra.Command + Name []string + Type []string +} + +func NewTemporalOperatorSearchAttributeCreateCommand(cctx *CommandContext, parent *TemporalOperatorSearchAttributeCommand) *TemporalOperatorSearchAttributeCreateCommand { + var s TemporalOperatorSearchAttributeCreateCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "create [flags]" + s.Command.Short = "Adds one or more custom Search Attributes" + if hasHighlighting { + s.Command.Long = "\x1b[1mtemporal operator search-attribute create\x1b[0m command adds one or more custom Search Attributes." + } else { + s.Command.Long = "`temporal operator search-attribute create` command adds one or more custom Search Attributes." + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringArrayVar(&s.Name, "name", nil, "Search Attribute name.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "name") + s.Command.Flags().StringArrayVar(&s.Type, "type", nil, "Search Attribute type. Accepted values: Text, Keyword, Int, Double, Bool, Datetime, KeywordList.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "type") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type TemporalOperatorSearchAttributeListCommand struct { + Parent *TemporalOperatorSearchAttributeCommand + Command cobra.Command +} + +func NewTemporalOperatorSearchAttributeListCommand(cctx *CommandContext, parent *TemporalOperatorSearchAttributeCommand) *TemporalOperatorSearchAttributeListCommand { + var s TemporalOperatorSearchAttributeListCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "list [flags]" + s.Command.Short = "Lists all Search Attributes that can be used in list Workflow Queries" + if hasHighlighting { + s.Command.Long = "\x1b[1mtemporal operator search-attribute list\x1b[0m displays a list of all Search Attributes." + } else { + s.Command.Long = "`temporal operator search-attribute list` displays a list of all Search Attributes." + } + 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 TemporalOperatorSearchAttributeRemoveCommand struct { + Parent *TemporalOperatorSearchAttributeCommand + Command cobra.Command + Name []string + Yes bool +} + +func NewTemporalOperatorSearchAttributeRemoveCommand(cctx *CommandContext, parent *TemporalOperatorSearchAttributeCommand) *TemporalOperatorSearchAttributeRemoveCommand { + var s TemporalOperatorSearchAttributeRemoveCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "remove [flags]" + s.Command.Short = "Removes custom search attribute metadata only" + if hasHighlighting { + s.Command.Long = "\x1b[1mtemporal operator search-attribute remove\x1b[0m command removes custom Search Attribute metadata." + } else { + s.Command.Long = "`temporal operator search-attribute remove` command removes custom Search Attribute metadata." + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringArrayVar(&s.Name, "name", nil, "Search Attribute name.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "name") + s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Confirm prompt to perform deletion.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type TemporalServerCommand struct { Parent *TemporalCommand Command cobra.Command diff --git a/temporalcli/commands.operator_search_attribute.go b/temporalcli/commands.operator_search_attribute.go new file mode 100644 index 00000000..e9ac0993 --- /dev/null +++ b/temporalcli/commands.operator_search_attribute.go @@ -0,0 +1,141 @@ +package temporalcli + +import ( + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/temporalio/cli/temporalcli/internal/printer" + "go.temporal.io/api/enums/v1" + "go.temporal.io/api/operatorservice/v1" +) + +func (c *TemporalOperatorSearchAttributeCreateCommand) run(cctx *CommandContext, args []string) error { + cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) + if err != nil { + return err + } + defer cl.Close() + + // Name and Type are required parameters, and there must be the same number of them. + + listReq := &operatorservice.ListSearchAttributesRequest{ + Namespace: c.Parent.Parent.Namespace, + } + existingSearchAttributes, err := cl.OperatorService().ListSearchAttributes(cctx, listReq) + if err != nil { + return fmt.Errorf("unable to get existing search attributes: %w", err) + } + + searchAttributes := make(map[string]enums.IndexedValueType, len(c.Type)) + for i, saType := range c.Type { + saName := c.Name[i] + typeInt, err := searchAttributeTypeStringToEnum(saType) + if err != nil { + return fmt.Errorf("unable to parse search attribute type %s: %w", saType, err) + } + existingSearchAttributeType, searchAttributeExists := existingSearchAttributes.CustomAttributes[saName] + if searchAttributeExists && existingSearchAttributeType != typeInt { + return fmt.Errorf("search attribute %s already exists and has different type %s", saName, existingSearchAttributeType.String()) + } + searchAttributes[saName] = typeInt + } + + request := &operatorservice.AddSearchAttributesRequest{ + SearchAttributes: searchAttributes, + Namespace: c.Parent.Parent.Namespace, + } + + _, err = cl.OperatorService().AddSearchAttributes(cctx, request) + if err != nil { + return fmt.Errorf("unable to add search attributes: %w", err) + } + cctx.Printer.Println(color.GreenString("Search attributes have been added")) + return nil +} + +func searchAttributeTypeStringToEnum(search string) (enums.IndexedValueType, error) { + for k, v := range enums.IndexedValueType_shorthandValue { + if strings.EqualFold(search, k) { + return enums.IndexedValueType(v), nil + } + } + return enums.INDEXED_VALUE_TYPE_UNSPECIFIED, fmt.Errorf("unsupported search attribute type: %v", search) +} + +func (c *TemporalOperatorSearchAttributeRemoveCommand) run(cctx *CommandContext, args []string) error { + cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) + if err != nil { + return err + } + defer cl.Close() + + yes, err := cctx.promptYes( + fmt.Sprintf("You are about to remove search attribute %q? y/N", c.Name), c.Yes) + if err != nil { + return err + } else if !yes { + // We consider this a command failure + return fmt.Errorf("user denied confirmation, operation aborted") + } + + names := c.Name + namespace := c.Parent.Parent.Namespace + if err != nil { + return err + } + + request := &operatorservice.RemoveSearchAttributesRequest{ + SearchAttributes: names, + Namespace: namespace, + } + + _, err = cl.OperatorService().RemoveSearchAttributes(cctx, request) + if err != nil { + return fmt.Errorf("unable to remove search attributes: %w", err) + } + + // response contains nothing + cctx.Printer.Println(color.GreenString("Search attributes have been removed")) + return nil +} + +func (c *TemporalOperatorSearchAttributeListCommand) run(cctx *CommandContext, args []string) error { + cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) + if err != nil { + return err + } + defer cl.Close() + + request := &operatorservice.ListSearchAttributesRequest{ + Namespace: c.Parent.Parent.Namespace, + } + resp, err := cl.OperatorService().ListSearchAttributes(cctx, request) + if err != nil { + return fmt.Errorf("unable to list search attributes: %w", err) + } + if cctx.JSONOutput { + return cctx.Printer.PrintStructured(resp, printer.StructuredOptions{}) + } + + type saNameType struct { + Name string `json:"name"` + Type string `json:"type"` + } + + var sas []saNameType + for saName, saType := range resp.SystemAttributes { + sas = append(sas, saNameType{ + Name: saName, + Type: saType.String(), + }) + } + for saName, saType := range resp.CustomAttributes { + sas = append(sas, saNameType{ + Name: saName, + Type: saType.String(), + }) + } + + return cctx.Printer.PrintStructured(sas, printer.StructuredOptions{Table: &printer.TableOptions{}}) +} diff --git a/temporalcli/commands.operator_search_attribute_test.go b/temporalcli/commands.operator_search_attribute_test.go new file mode 100644 index 00000000..efed38b6 --- /dev/null +++ b/temporalcli/commands.operator_search_attribute_test.go @@ -0,0 +1,100 @@ +package temporalcli_test + +import ( + "github.com/temporalio/cli/temporalcli" + "go.temporal.io/api/enums/v1" + "go.temporal.io/api/operatorservice/v1" +) + +func (s *SharedServerSuite) TestOperator_SearchAttribute_Create_Already_Exists() { + /* + This test try to create: + 1, a system search attribute, that should fail. + 2, a custom search attribute with different type, that should fail. + 3, a custom search attribute with same type, that should pass. + */ + // Create System search attribute (WorkflowId) + res := s.Execute( + "operator", "search-attribute", "create", + "--address", s.Address(), + "--name", "WorkflowId", + "--type", "Keyword", + ) + s.Error(res.Err) + s.Contains(res.Err.Error(), "unable to add search attributes: Search attribute WorkflowId is reserved by system.") + + // Create Custom search attribute with different type + res2 := s.Execute( + "operator", "search-attribute", "create", + "--address", s.Address(), + "--name", "CustomKeywordField", + "--type", "Int", + ) + s.Error(res2.Err) + s.Contains(res2.Err.Error(), "search attribute CustomKeywordField already exists and has different type Keyword") + + // Create Custom search attribute with same type + res3 := s.Execute( + "operator", "search-attribute", "create", + "--address", s.Address(), + "--name", "CustomKeywordField", + "--type", "Keyword", + ) + s.NoError(res3.Err) +} + +func (s *SharedServerSuite) TestOperator_SearchAttribute() { + /* + This test case first create 2 new custom search attributes, and verify the creation by list them. + Then we delete one of the newly created SA, and verify the deletion by list again. + */ + res := s.Execute( + "operator", "search-attribute", "create", + "--address", s.Address(), + "--name", "MySearchAttributeForTestCreateKeyword", + "--type", "Keyword", + "--name", "MySearchAttributeForTestCreateInt", + "--type", "Int", + ) + s.NoError(res.Err) + + // verify the creation succeed + res2 := s.Execute( + "operator", "search-attribute", "list", + "--address", s.Address(), + "-o", "json", + ) + s.NoError(res2.Err) + var jsonOut operatorservice.ListSearchAttributesResponse + s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res2.Stdout.Bytes(), &jsonOut, true)) + s.Equal(enums.INDEXED_VALUE_TYPE_KEYWORD, jsonOut.CustomAttributes["MySearchAttributeForTestCreateKeyword"]) + s.Equal(enums.INDEXED_VALUE_TYPE_INT, jsonOut.CustomAttributes["MySearchAttributeForTestCreateInt"]) + + // Remove it + res3 := s.Execute( + "operator", "search-attribute", "remove", + "--address", s.Address(), + "--name", "MySearchAttributeForTestCreateKeyword", + "--yes", + ) + s.NoError(res3.Err) + + // verify deletion + res4 := s.Execute( + "operator", "search-attribute", "list", + "--address", s.Address(), + "-o", "json", + ) + s.NoError(res2.Err) + var jsonOut2 operatorservice.ListSearchAttributesResponse + s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res4.Stdout.Bytes(), &jsonOut2, true)) + // Int SA still exists + s.Equal(enums.INDEXED_VALUE_TYPE_INT, jsonOut2.CustomAttributes["MySearchAttributeForTestCreateInt"]) + // Keyword SA no longer exists + _, exists := jsonOut2.CustomAttributes["MySearchAttributeForTestCreateKeyword"] + s.False(exists) + + // also verify some system attributes from list result + s.Equal(enums.INDEXED_VALUE_TYPE_DATETIME, jsonOut.SystemAttributes["StartTime"]) + s.Equal(enums.INDEXED_VALUE_TYPE_KEYWORD, jsonOut.SystemAttributes["WorkflowId"]) +} diff --git a/temporalcli/commandsmd/commands.md b/temporalcli/commandsmd/commands.md index c19c46bd..61ad8570 100644 --- a/temporalcli/commandsmd/commands.md +++ b/temporalcli/commandsmd/commands.md @@ -209,6 +209,32 @@ Cluster commands follow this syntax: `temporal operator cluster [command] [comma * `--frontend-address` (string) - IP address to bind the frontend service to. Required. * `--enable-connection` (bool) - enable cross cluster connection. +### temporal operator search-attribute: Operations applying to Search Attributes + +Search Attribute commands enable operations for the creation, listing, and removal of Search Attributes. + +### temporal operator search-attribute create: Adds one or more custom Search Attributes + +`temporal operator search-attribute create` command adds one or more custom Search Attributes. + +#### Options + +* `--name` (string[]) - Search Attribute name. Required. +* `--type` (string[]) - Search Attribute type. Options: Text, Keyword, Int, Double, Bool, Datetime, KeywordList. Required. + +### temporal operator search-attribute list: Lists all Search Attributes that can be used in list Workflow Queries + +`temporal operator search-attribute list` displays a list of all Search Attributes. + +### temporal operator search-attribute remove: Removes custom search attribute metadata only + +`temporal operator search-attribute remove` command removes custom Search Attribute metadata. + +#### Options + +* `--name` (string[]) - Search Attribute name. Required. +* `--yes`, `-y` (bool) - Confirm prompt to perform deletion. + ### temporal server: Run Temporal Server. Start a development version of [Temporal Server](/concepts/what-is-the-temporal-server):