diff --git a/client/main.go b/client/main.go index efe09518e..9e71da925 100644 --- a/client/main.go +++ b/client/main.go @@ -25,7 +25,7 @@ var ( EventsClient events.EventsServiceClient ) -// FullClient aggregates all the grpc clients available from Tinkerbell Server +// FullClient aggregates all the gRPC clients available from Tinkerbell Server type FullClient struct { TemplateClient template.TemplateServiceClient WorkflowClient workflow.WorkflowServiceClient @@ -35,8 +35,8 @@ type FullClient struct { // NewFullClientFromGlobal is a dirty hack that returns a FullClient using the // global variables exposed by the client package. Globals should be avoided -// and we will deprecated them at some point replacing this function with -// NewFullClient. If you are strating a new project please use the last one +// and we will deprecate them at some point replacing this function with +// NewFullClient. If you are starting a new project please use NewFullClient instead. func NewFullClientFromGlobal() (*FullClient, error) { // This is required because we use init() too often, even more in the // CLI and based on where you are sometime the clients are not initialised diff --git a/cmd/tink-cli/cmd/delete/delete.go b/cmd/tink-cli/cmd/delete/delete.go new file mode 100644 index 000000000..a053a795e --- /dev/null +++ b/cmd/tink-cli/cmd/delete/delete.go @@ -0,0 +1,123 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/tinkerbell/tink/client" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type Options struct { + // DeleteByID is used to delete a resource + DeleteByID func(context.Context, *client.FullClient, string) (interface{}, error) + + clientConnOpt *client.ConnOptions + fullClient *client.FullClient +} + +func (o *Options) SetClientConnOpt(co *client.ConnOptions) { + o.clientConnOpt = co +} + +func (o *Options) SetFullClient(cl *client.FullClient) { + o.fullClient = cl +} + +const shortDescr = "delete one or more resources" + +const longDescr = `Deletes one or more resources and prints the status of +the deleted resource. + +# Delete template resource (success) +tink template delete 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 +Deleted 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 + +# Delete template resource (not found) +tink template delete 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 +Error 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 not found + +# Delete template resources (one not found) +tink template delete 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 e4115856-4358-429d-a8f6-9e1b7d794b72 +Deleted 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 +Error e4115856-4358-429d-a8f6-9e1b7d794b72 not found + +# Delete resources and extract resource ID with awk +tink template delete 8ae1cc24-6a9c-11eb-a0fc-0242ac120005 e4115856-4358-429d-a8f6-9e1b7d794b72 | awk {print $2} > result +cat result +8ae1cc24-6a9c-11eb-a0fc-0242ac120005 +e4115856-4358-429d-a8f6-9e1b7d794b72 +` + +const exampleDescr = `# Delete template resource +tink template delete [id] + +# Delete hardware resource +tink hardware delete [id] + +# Delete workflow resource +tink workflow delete [id] + +# Delete multiple workflow resources +tink workflow delete [id_1] [id_2] [id_3] +` + +func NewDeleteCommand(opt Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: shortDescr, + Long: longDescr, + Example: exampleDescr, + DisableFlagsInUseLine: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if opt.fullClient != nil { + return nil + } + if opt.clientConnOpt == nil { + opt.SetClientConnOpt(&client.ConnOptions{}) + } + opt.clientConnOpt.SetFlags(cmd.PersistentFlags()) + return nil + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + if opt.fullClient == nil { + var err error + var conn *grpc.ClientConn + conn, err = client.NewClientConn(opt.clientConnOpt) + if err != nil { + println("Flag based client configuration failed with err: %s. Trying with env var legacy method...", err) + // Fallback to legacy Setup via env var + conn, err = client.GetConnection() + if err != nil { + return errors.Wrap(err, "failed to setup connection to tink-server") + } + } + opt.SetFullClient(client.NewFullClient(conn)) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if opt.DeleteByID == nil { + return errors.New("DeleteByID is not implemented for this resource yet. Please have a look at the issue in GitHub or open a new one.") + } + for _, requestedID := range args { + _, err := opt.DeleteByID(cmd.Context(), opt.fullClient, requestedID) + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { + fmt.Fprintf(cmd.ErrOrStderr(), "Error\t%s\tnot found\n", requestedID) + continue + } else { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "Deleted\t%s\n", requestedID) + } + return nil + }, + } + return cmd +} diff --git a/cmd/tink-cli/cmd/delete/delete_test.go b/cmd/tink-cli/cmd/delete/delete_test.go new file mode 100644 index 000000000..0b99f057e --- /dev/null +++ b/cmd/tink-cli/cmd/delete/delete_test.go @@ -0,0 +1,98 @@ +package delete + +import ( + "bytes" + "context" + "io/ioutil" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tinkerbell/tink/client" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestNewDeleteCommand(t *testing.T) { + table := []struct { + name string + expectedOutput string + args []string + opt Options + }{ + { + name: "happy-path", + expectedOutput: "Deleted\tbeeb5c79\n", + args: []string{"beeb5c79"}, + opt: Options{ + DeleteByID: func(c context.Context, fc *client.FullClient, s string) (interface{}, error) { + return struct{}{}, nil + }, + }, + }, + { + name: "happy-path-multiple-resources", + expectedOutput: "Deleted\tbeeb5c79\nDeleted\t14810952\nDeleted\te7a91fe9\n", + args: []string{"beeb5c79", "14810952", "e7a91fe9"}, + opt: Options{ + DeleteByID: func(c context.Context, fc *client.FullClient, s string) (interface{}, error) { + return struct{}{}, nil + }, + }, + }, + { + name: "resource-not-found", + expectedOutput: "Error\tbeeb5c79\tnot found\n", + args: []string{"beeb5c79"}, + opt: Options{ + DeleteByID: func(c context.Context, fc *client.FullClient, s string) (interface{}, error) { + return struct{}{}, status.Error(codes.NotFound, "") + }, + }, + }, + { + name: "multiple-resources-not-found", + expectedOutput: "Error\tbeeb5c79\tnot found\nError\t14810952\tnot found\n", + args: []string{"beeb5c79", "14810952"}, + opt: Options{ + DeleteByID: func(c context.Context, fc *client.FullClient, s string) (interface{}, error) { + return struct{}{}, status.Error(codes.NotFound, "") + }, + }, + }, + { + name: "only-one-resource-of-two-was-deleted", + expectedOutput: "Deleted\tbeeb5c79\nError\t14810952\tnot found\n", + args: []string{"beeb5c79", "14810952"}, + opt: Options{ + DeleteByID: func(c context.Context, fc *client.FullClient, s string) (interface{}, error) { + if s == "beeb5c79" { + return struct{}{}, nil + } + return struct{}{}, status.Error(codes.NotFound, "") + }, + }, + }, + } + + for _, test := range table { + t.Run(test.name, func(t *testing.T) { + stdout := bytes.NewBufferString("") + test.opt.SetFullClient(&client.FullClient{}) + cmd := NewDeleteCommand(test.opt) + cmd.SetOut(stdout) + cmd.SetErr(stdout) + cmd.SetArgs(test.args) + err := cmd.Execute() + if err != nil { + t.Error(err) + } + out, err := ioutil.ReadAll(stdout) + if err != nil { + t.Error(err) + } + if diff := cmp.Diff(string(out), test.expectedOutput); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/cmd/tink-cli/cmd/delete/doc.go b/cmd/tink-cli/cmd/delete/doc.go new file mode 100644 index 000000000..d50da3537 --- /dev/null +++ b/cmd/tink-cli/cmd/delete/doc.go @@ -0,0 +1,4 @@ +// Package delete provides a reusable implementation of the Delete command +// for the tink cli. The Delete command deletes resources. It is designed +// to be extendible and usable across resources. +package delete diff --git a/cmd/tink-cli/cmd/get/get_test.go b/cmd/tink-cli/cmd/get/get_test.go index fe248db3d..e6632a685 100644 --- a/cmd/tink-cli/cmd/get/get_test.go +++ b/cmd/tink-cli/cmd/get/get_test.go @@ -124,7 +124,7 @@ func TestNewGetCommand(t *testing.T) { }, { Name: "happy-path-json-no-headers", - Skip: "The JSON format is rusty and custom because we table library we use do not support JSON right now. This feature is not implemented", + Skip: "The JSON format is rusty and custom because the table library we use does not support JSON right now. This feature is not implemented.", }, { Name: "happy-path-csv-no-headers", @@ -198,5 +198,4 @@ func TestNewGetCommand(t *testing.T) { } }) } - } diff --git a/cmd/tink-cli/cmd/hardware.go b/cmd/tink-cli/cmd/hardware.go index ac6403806..17fa38c72 100644 --- a/cmd/tink-cli/cmd/hardware.go +++ b/cmd/tink-cli/cmd/hardware.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/tinkerbell/tink/cmd/tink-cli/cmd/delete" "github.com/tinkerbell/tink/cmd/tink-cli/cmd/get" "github.com/tinkerbell/tink/cmd/tink-cli/cmd/hardware" ) @@ -22,7 +23,7 @@ func NewHardwareCommand() *cobra.Command { } cmd.AddCommand(get.NewGetCommand(hardware.NewGetOptions())) - cmd.AddCommand(hardware.NewDeleteCmd()) + cmd.AddCommand(delete.NewDeleteCommand(hardware.NewDeleteOptions())) cmd.AddCommand(hardware.NewGetByIDCmd()) cmd.AddCommand(hardware.NewGetByIPCmd()) cmd.AddCommand(hardware.NewListCmd()) diff --git a/cmd/tink-cli/cmd/hardware/delete.go b/cmd/tink-cli/cmd/hardware/delete.go index 97709d70d..3376bb5aa 100644 --- a/cmd/tink-cli/cmd/hardware/delete.go +++ b/cmd/tink-cli/cmd/hardware/delete.go @@ -4,30 +4,23 @@ package hardware import ( "context" - "log" - "github.com/spf13/cobra" "github.com/tinkerbell/tink/client" + "github.com/tinkerbell/tink/cmd/tink-cli/cmd/delete" "github.com/tinkerbell/tink/protos/hardware" ) -func NewDeleteCmd() *cobra.Command { - return &cobra.Command{ - Use: "delete", - Short: "delete hardware by id", - Example: "tink hardware delete 224ee6ab-ad62-4070-a900-ed816444cec0 cb76ae54-93e9-401c-a5b2-d455bb3800b1", - Args: func(_ *cobra.Command, args []string) error { - return verifyUUIDs(args) - }, - Run: func(cmd *cobra.Command, args []string) { - for _, id := range args { - _, err := client.HardwareClient.Delete(context.Background(), &hardware.DeleteRequest{Id: id}) - if err != nil { - log.Fatal(err) - } - log.Println("Hardware data with id", id, "deleted successfully") - } - }, - } +type deleteHardware struct { + delete.Options +} +func (h *deleteHardware) DeleteByID(ctx context.Context, cl *client.FullClient, requestedID string) (interface{}, error) { + return cl.HardwareClient.Delete(ctx, &hardware.DeleteRequest{Id: requestedID}) +} + +func NewDeleteOptions() delete.Options { + h := deleteHardware{} + return delete.Options{ + DeleteByID: h.DeleteByID, + } } diff --git a/cmd/tink-cli/cmd/hardware_test.go b/cmd/tink-cli/cmd/hardware_test.go index 51c9e7d64..45d5bb863 100644 --- a/cmd/tink-cli/cmd/hardware_test.go +++ b/cmd/tink-cli/cmd/hardware_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "fmt" "strings" "testing" @@ -112,8 +113,9 @@ func Test_NewHardwareCommand(t *testing.T) { if err := root.Execute(); err != nil { t.Error(err) } - if !strings.Contains(out.String(), "delete hardware by id") { - t.Error("expected output should include delete hardware by id") + want := "Deletes one or more resources" + if !strings.Contains(out.String(), want) { + t.Error(fmt.Errorf("unexpected output, looking for %q as a substring in %q", want, out.String())) } }, }, diff --git a/cmd/tink-cli/cmd/template.go b/cmd/tink-cli/cmd/template.go index f78fb2310..7f1a0f934 100644 --- a/cmd/tink-cli/cmd/template.go +++ b/cmd/tink-cli/cmd/template.go @@ -5,6 +5,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/tinkerbell/tink/cmd/tink-cli/cmd/delete" "github.com/tinkerbell/tink/cmd/tink-cli/cmd/get" "github.com/tinkerbell/tink/cmd/tink-cli/cmd/template" ) @@ -23,12 +24,12 @@ func NewTemplateCommand() *cobra.Command { } cmd.AddCommand(template.NewCreateCommand()) - cmd.AddCommand(template.NewDeleteCommand()) + cmd.AddCommand(delete.NewDeleteCommand(template.NewDeleteOptions())) cmd.AddCommand(template.NewListCommand()) cmd.AddCommand(template.NewUpdateCommand()) - // If the variable TINK_CLI_VERSION is not set to 0.0.0 use the old get - // command. This is a way to keep retro-compatibility with the old get command. + // If the variable TINK_CLI_VERSION is set to 0.0.0 use the old get command. + // This is a way to keep retro-compatibility with the old get command. getCmd := template.GetCmd if v := os.Getenv("TINK_CLI_VERSION"); v != "0.0.0" { getCmd = get.NewGetCommand(template.NewGetOptions()) diff --git a/cmd/tink-cli/cmd/template/delete.go b/cmd/tink-cli/cmd/template/delete.go index bb2a719c8..d63820c78 100644 --- a/cmd/tink-cli/cmd/template/delete.go +++ b/cmd/tink-cli/cmd/template/delete.go @@ -2,45 +2,27 @@ package template import ( "context" - "fmt" - "log" - "github.com/google/uuid" - "github.com/spf13/cobra" "github.com/tinkerbell/tink/client" + "github.com/tinkerbell/tink/cmd/tink-cli/cmd/delete" "github.com/tinkerbell/tink/protos/template" ) -// deleteCmd represents the delete subcommand for template command -func NewDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete [id]", - Short: "delete a template", - Example: "tink template delete [id]", - DisableFlagsInUseLine: true, - Args: func(c *cobra.Command, args []string) error { - if len(args) == 0 { - return fmt.Errorf("%v requires an argument", c.UseLine()) - } - for _, arg := range args { - if _, err := uuid.Parse(arg); err != nil { - return fmt.Errorf("invalid uuid: %s", arg) - } - } - return nil - }, - Run: func(c *cobra.Command, args []string) { - for _, arg := range args { - req := template.GetRequest{ - GetBy: &template.GetRequest_Id{ - Id: arg, - }, - } - if _, err := client.TemplateClient.DeleteTemplate(context.Background(), &req); err != nil { - log.Fatal(err) - } - } +type deleteTemplate struct { + delete.Options +} + +func (d *deleteTemplate) DeleteByID(ctx context.Context, cl *client.FullClient, requestedID string) (interface{}, error) { + return cl.TemplateClient.DeleteTemplate(ctx, &template.GetRequest{ + GetBy: &template.GetRequest_Id{ + Id: requestedID, }, + }) +} + +func NewDeleteOptions() delete.Options { + t := deleteTemplate{} + return delete.Options{ + DeleteByID: t.DeleteByID, } - return cmd } diff --git a/cmd/tink-cli/cmd/template/get.go b/cmd/tink-cli/cmd/template/get.go index dbe4879c9..1d438eb32 100644 --- a/cmd/tink-cli/cmd/template/get.go +++ b/cmd/tink-cli/cmd/template/get.go @@ -60,7 +60,7 @@ func (h *getTemplate) RetrieveByID(ctx context.Context, cl *client.FullClient, r } func (h *getTemplate) RetrieveData(ctx context.Context, cl *client.FullClient) ([]interface{}, error) { - list, err := cl.TemplateClient.ListTemplates(context.Background(), &template.ListRequest{ + list, err := cl.TemplateClient.ListTemplates(ctx, &template.ListRequest{ FilterBy: &template.ListRequest_Name{ Name: "*", }, @@ -88,6 +88,7 @@ func (h *getTemplate) PopulateTable(data []interface{}, t table.Writer) error { } return nil } + func NewGetOptions() get.Options { h := getTemplate{} return get.Options{ diff --git a/cmd/tink-cli/cmd/workflow.go b/cmd/tink-cli/cmd/workflow.go index 4e667a834..d2e2ba0e9 100644 --- a/cmd/tink-cli/cmd/workflow.go +++ b/cmd/tink-cli/cmd/workflow.go @@ -5,6 +5,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/tinkerbell/tink/cmd/tink-cli/cmd/delete" "github.com/tinkerbell/tink/cmd/tink-cli/cmd/get" "github.com/tinkerbell/tink/cmd/tink-cli/cmd/workflow" ) @@ -24,7 +25,7 @@ func NewWorkflowCommand() *cobra.Command { cmd.AddCommand(workflow.NewCreateCommand()) cmd.AddCommand(workflow.NewDataCommand()) - cmd.AddCommand(workflow.NewDeleteCommand()) + cmd.AddCommand(delete.NewDeleteCommand(workflow.NewDeleteOptions())) cmd.AddCommand(workflow.NewShowCommand()) cmd.AddCommand(workflow.NewListCommand()) cmd.AddCommand(workflow.NewStateCommand()) diff --git a/cmd/tink-cli/cmd/workflow/delete.go b/cmd/tink-cli/cmd/workflow/delete.go index 5dd3dc2d1..20eb33430 100644 --- a/cmd/tink-cli/cmd/workflow/delete.go +++ b/cmd/tink-cli/cmd/workflow/delete.go @@ -2,40 +2,23 @@ package workflow import ( "context" - "fmt" - "log" - "github.com/google/uuid" - "github.com/spf13/cobra" "github.com/tinkerbell/tink/client" + "github.com/tinkerbell/tink/cmd/tink-cli/cmd/delete" "github.com/tinkerbell/tink/protos/workflow" ) -func NewDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete [id]", - Short: "delete a workflow", - Example: "tink workflow delete [id]", - DisableFlagsInUseLine: true, - Args: func(c *cobra.Command, args []string) error { - if len(args) == 0 { - return fmt.Errorf("%v requires an argument", c.UseLine()) - } - for _, arg := range args { - if _, err := uuid.Parse(arg); err != nil { - return fmt.Errorf("invalid uuid: %s", arg) - } - } - return nil - }, - Run: func(c *cobra.Command, args []string) { - for _, arg := range args { - req := workflow.GetRequest{Id: arg} - if _, err := client.WorkflowClient.DeleteWorkflow(context.Background(), &req); err != nil { - log.Fatal(err) - } - } - }, +type deleteWorkflow struct { + delete.Options +} + +func (d *deleteWorkflow) DeleteByID(ctx context.Context, cl *client.FullClient, requestedID string) (interface{}, error) { + return cl.WorkflowClient.DeleteWorkflow(ctx, &workflow.GetRequest{Id: requestedID}) +} + +func NewDeleteOptions() delete.Options { + w := deleteWorkflow{} + return delete.Options{ + DeleteByID: w.DeleteByID, } - return cmd } diff --git a/db/hardware.go b/db/hardware.go index f378f7c82..19b49ab43 100644 --- a/db/hardware.go +++ b/db/hardware.go @@ -3,9 +3,12 @@ package db import ( "context" "database/sql" + "fmt" "time" "github.com/pkg/errors" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // DeleteFromDB : delete data from hardware table @@ -15,7 +18,7 @@ func (d TinkDB) DeleteFromDB(ctx context.Context, id string) error { return errors.Wrap(err, "BEGIN transaction") } - _, err = tx.Exec(` + res, err := tx.Exec(` UPDATE hardware SET deleted_at = NOW() @@ -27,6 +30,10 @@ func (d TinkDB) DeleteFromDB(ctx context.Context, id string) error { return errors.Wrap(err, "DELETE") } + if count, _ := res.RowsAffected(); count == int64(0) { + return status.Error(codes.NotFound, fmt.Sprintf("not found, id:%s", id)) + } + err = tx.Commit() if err != nil { return errors.Wrap(err, "COMMIT") diff --git a/db/template.go b/db/template.go index c3b0fe27b..dccdc2a9b 100644 --- a/db/template.go +++ b/db/template.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + "fmt" "time" "github.com/golang/protobuf/ptypes" @@ -10,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" wflow "github.com/tinkerbell/tink/workflow" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // CreateTemplate creates a new workflow template @@ -91,7 +94,7 @@ func (d TinkDB) DeleteTemplate(ctx context.Context, id string) error { return errors.Wrap(err, "BEGIN transaction") } - _, err = tx.Exec(` + res, err := tx.Exec(` UPDATE template SET deleted_at = NOW() @@ -102,6 +105,10 @@ func (d TinkDB) DeleteTemplate(ctx context.Context, id string) error { return errors.Wrap(err, "UPDATE") } + if count, _ := res.RowsAffected(); count == int64(0) { + return status.Error(codes.NotFound, fmt.Sprintf("not found, id:%s", id)) + } + err = tx.Commit() if err != nil { return errors.Wrap(err, "COMMIT") diff --git a/db/workflow.go b/db/workflow.go index 7db4d349e..9169ccbe2 100644 --- a/db/workflow.go +++ b/db/workflow.go @@ -17,6 +17,8 @@ import ( "github.com/pkg/errors" pb "github.com/tinkerbell/tink/protos/workflow" wflow "github.com/tinkerbell/tink/workflow" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Workflow represents a workflow instance in database @@ -381,7 +383,7 @@ func (d TinkDB) DeleteWorkflow(ctx context.Context, id string, state int32) erro return errors.Wrap(err, "Delete Workflow Error") } - _, err = tx.Exec(` + res, err := tx.Exec(` UPDATE workflow SET deleted_at = NOW() @@ -392,6 +394,10 @@ func (d TinkDB) DeleteWorkflow(ctx context.Context, id string, state int32) erro return errors.Wrap(err, "UPDATE") } + if count, _ := res.RowsAffected(); count == int64(0) { + return status.Error(codes.NotFound, fmt.Sprintf("not found, id:%s", id)) + } + err = tx.Commit() if err != nil { return errors.Wrap(err, "COMMIT")