diff --git a/cli/feast/cmd/get.go b/cli/feast/cmd/get.go new file mode 100644 index 0000000000..4efd705d6d --- /dev/null +++ b/cli/feast/cmd/get.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/gojek/feast/cli/feast/pkg/printer" + "github.com/gojek/feast/protos/generated/go/feast/core" + "github.com/spf13/cobra" +) + +// listCmd represents the list command +var getCmd = &cobra.Command{ + Use: "get [resource] [id]", + Short: "Get and print the details of the desired resource.", + Long: `Get and print the details of the desired resource. + +Valid resources include: +- entity +- feature +- storage +- job + +Examples: +- feast get entity myentity`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + + if len(args) != 2 { + return errors.New("invalid number of arguments for list command") + } + + initConn() + err := get(args[0], args[1]) + if err != nil { + return fmt.Errorf("failed to list %s: %v", args[0], err) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(getCmd) +} + +func get(resource string, id string) error { + ctx := context.Background() + + switch resource { + case "feature": + return getFeature(ctx, core.NewUIServiceClient(coreConn), id) + case "entity": + return getEntity(ctx, core.NewUIServiceClient(coreConn), id) + case "storage": + return getStorage(ctx, core.NewUIServiceClient(coreConn), id) + case "job": + return getJob(ctx, core.NewJobServiceClient(coreConn), id) + default: + return fmt.Errorf("invalid resource %s: please choose one of [features, entities, storage, jobs]", resource) + } +} + +func getFeature(ctx context.Context, cli core.UIServiceClient, id string) error { + response, err := cli.GetFeature(ctx, &core.UIServiceTypes_GetFeatureRequest{Id: id}) + if err != nil { + return err + } + printer.PrintFeatureDetail(response.GetFeature()) + return nil +} + +func getEntity(ctx context.Context, cli core.UIServiceClient, id string) error { + response, err := cli.GetEntity(ctx, &core.UIServiceTypes_GetEntityRequest{Id: id}) + if err != nil { + return err + } + printer.PrintEntityDetail(response.GetEntity()) + return nil +} + +func getStorage(ctx context.Context, cli core.UIServiceClient, id string) error { + response, err := cli.GetStorage(ctx, &core.UIServiceTypes_GetStorageRequest{Id: id}) + if err != nil { + return err + } + printer.PrintStorageDetail(response.GetStorage()) + return nil +} + +func getJob(ctx context.Context, cli core.JobServiceClient, id string) error { + response, err := cli.GetJob(ctx, &core.JobServiceTypes_GetJobRequest{Id: id}) + if err != nil { + return err + } + printer.PrintJobDetail(response.GetJob()) + return nil +} diff --git a/cli/feast/cmd/jobs.go b/cli/feast/cmd/jobs.go index d6b317f7d6..962e37e56e 100644 --- a/cli/feast/cmd/jobs.go +++ b/cli/feast/cmd/jobs.go @@ -21,7 +21,6 @@ import ( "io/ioutil" "github.com/gojek/feast/cli/feast/pkg/parse" - "github.com/gojek/feast/cli/feast/pkg/printer" "github.com/gojek/feast/protos/generated/go/feast/core" "github.com/spf13/cobra" @@ -48,21 +47,6 @@ var jobsRunCmd = &cobra.Command{ }, } -var jobsInfoCmd = &cobra.Command{ - Use: "info [job_id]", - Short: "Get details for a single job", - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return cmd.Help() - } - if len(args) > 1 { - return errors.New("invalid number of arguments for jobs info command") - } - ctx := context.Background() - return getJob(ctx, args[0]) - }, -} - var jobsAbortCmd = &cobra.Command{ Use: "stop [job_id]", Short: "Stop the given job", @@ -80,7 +64,6 @@ var jobsAbortCmd = &cobra.Command{ func init() { jobsCmd.AddCommand(jobsRunCmd) - jobsCmd.AddCommand(jobsInfoCmd) jobsCmd.AddCommand(jobsAbortCmd) rootCmd.AddCommand(jobsCmd) } @@ -106,17 +89,6 @@ func runJob(ctx context.Context, path string) error { return nil } -func getJob(ctx context.Context, id string) error { - initConn() - jobsClient := core.NewJobServiceClient(coreConn) - response, err := jobsClient.GetJob(ctx, &core.JobServiceTypes_GetJobRequest{Id: id}) - if err != nil { - return err - } - printer.PrintJobDetail(response.GetJob()) - return nil -} - func abortJob(ctx context.Context, id string) error { initConn() jobsClient := core.NewJobServiceClient(coreConn) diff --git a/cli/feast/pkg/printer/job.go b/cli/feast/pkg/printer/job.go index 32673b4153..56f9ac8e8b 100644 --- a/cli/feast/pkg/printer/job.go +++ b/cli/feast/pkg/printer/job.go @@ -22,8 +22,9 @@ import ( "github.com/gojek/feast/protos/generated/go/feast/core" ) -// PrintJobDetail pretty prints the given job detail -func PrintJobDetail(jobDetail *core.JobServiceTypes_JobDetail) { +// PrintJobDetail pretty prints the given job detail. +// Prints and returns the resultant formatted string. +func PrintJobDetail(jobDetail *core.JobServiceTypes_JobDetail) string { lines := []string{fmt.Sprintf("%s:\t%s", "Id", jobDetail.GetId()), fmt.Sprintf("%s:\t%s", "Ext Id", jobDetail.GetExtId()), fmt.Sprintf("%s:\t%s", "Type", jobDetail.GetType()), @@ -46,7 +47,9 @@ func PrintJobDetail(jobDetail *core.JobServiceTypes_JobDetail) { for _, feature := range jobDetail.GetFeatures() { lines = append(lines, printFeature(feature, jobDetail.GetMetrics())) } - fmt.Println(strings.Join(lines, "\n")) + out := strings.Join(lines, "\n") + fmt.Println(out) + return out } func printEntity(entityName string, metrics map[string]float64) string { diff --git a/cli/feast/pkg/printer/printer_test.go b/cli/feast/pkg/printer/printer_test.go new file mode 100644 index 0000000000..c89751262d --- /dev/null +++ b/cli/feast/pkg/printer/printer_test.go @@ -0,0 +1,146 @@ +package printer + +import ( + "testing" + + "github.com/golang/protobuf/ptypes/timestamp" + + "github.com/gojek/feast/protos/generated/go/feast/core" + "github.com/gojek/feast/protos/generated/go/feast/specs" + "github.com/gojek/feast/protos/generated/go/feast/types" +) + +func TestPrintFeature(t *testing.T) { + tt := []struct { + name string + input *core.UIServiceTypes_FeatureDetail + expected string + }{ + { + name: "with storage", + input: &core.UIServiceTypes_FeatureDetail{ + Spec: &specs.FeatureSpec{ + Id: "test.none.test_feature_two", + Owner: "bob@example.com", + Name: "test_feature_two", + Description: "testing feature", + Uri: "https://github.com/bob/example", + Granularity: types.Granularity_NONE, + ValueType: types.ValueType_INT64, + Entity: "test", + DataStores: &specs.DataStores{ + Serving: &specs.DataStore{ + Id: "REDIS", + }, + Warehouse: &specs.DataStore{ + Id: "BIGQUERY", + }, + }, + }, + BigqueryView: "bqurl", + Jobs: []string{"job1", "job2"}, + LastUpdated: ×tamp.Timestamp{Seconds: 1}, + Created: ×tamp.Timestamp{Seconds: 1}, + }, + expected: `Id: test.none.test_feature_two +Entity: test +Owner: bob@example.com +Description: testing feature +ValueType: INT64 +Uri: https://github.com/bob/example +DataStores: + Serving: REDIS + Warehouse: BIGQUERY +Created: 1970-01-01T07:30:01+07:30 +LastUpdated: 1970-01-01T07:30:01+07:30 +Related Jobs: +- job1 +- job2`, + }, { + name: "no storage", + input: &core.UIServiceTypes_FeatureDetail{ + Spec: &specs.FeatureSpec{ + Id: "test.none.test_feature_two", + Owner: "bob@example.com", + Name: "test_feature_two", + Description: "testing feature", + Uri: "https://github.com/bob/example", + Granularity: types.Granularity_NONE, + ValueType: types.ValueType_INT64, + Entity: "test", + }, + BigqueryView: "bqurl", + Jobs: []string{"job1", "job2"}, + LastUpdated: ×tamp.Timestamp{Seconds: 1}, + Created: ×tamp.Timestamp{Seconds: 1}, + }, + expected: `Id: test.none.test_feature_two +Entity: test +Owner: bob@example.com +Description: testing feature +ValueType: INT64 +Uri: https://github.com/bob/example +Created: 1970-01-01T07:30:01+07:30 +LastUpdated: 1970-01-01T07:30:01+07:30 +Related Jobs: +- job1 +- job2`, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + out := PrintFeatureDetail(tc.input) + if out != tc.expected { + t.Errorf("Expected output:\n%s \nActual:\n%s \n", tc.expected, out) + } + }) + } +} + +func TestPrintEntity(t *testing.T) { + entityDetail := &core.UIServiceTypes_EntityDetail{ + Spec: &specs.EntitySpec{ + Name: "test", + Description: "my test entity", + Tags: []string{"tag1", "tag2"}, + }, + Jobs: []string{"job1", "job2"}, + LastUpdated: ×tamp.Timestamp{Seconds: 1}, + } + out := PrintEntityDetail(entityDetail) + expected := `Name: test +Description: my test entity +Tags: tag1,tag2 +LastUpdated: 1970-01-01T07:30:01+07:30 +Related Jobs: +- job1 +- job2` + if out != expected { + t.Errorf("Expected output:\n%s \nActual:\n%s \n", expected, out) + } +} + +func TestPrintStorage(t *testing.T) { + storageDetail := &core.UIServiceTypes_StorageDetail{ + Spec: &specs.StorageSpec{ + Id: "REDIS1", + Type: "redis", + Options: map[string]string{ + "option1": "value1", + "option2": "value2", + }, + }, + LastUpdated: ×tamp.Timestamp{Seconds: 1}, + } + out := PrintStorageDetail(storageDetail) + expected := `Id: REDIS1 +Type: redis +Options: + option1: value1 + option2: value2 +LastUpdated: 1970-01-01T07:30:01+07:30` + if out != expected { + t.Errorf("Expected output:\n%s \nActual:\n%s \n", expected, out) + } +} diff --git a/cli/feast/pkg/printer/specs.go b/cli/feast/pkg/printer/specs.go new file mode 100644 index 0000000000..8c88bffbfe --- /dev/null +++ b/cli/feast/pkg/printer/specs.go @@ -0,0 +1,85 @@ +package printer + +import ( + "fmt" + "strings" + + "github.com/gojek/feast/cli/feast/pkg/util" + "github.com/gojek/feast/protos/generated/go/feast/core" +) + +// PrintFeatureDetail prints the details about the feature. +// Prints and returns the resultant formatted string. +func PrintFeatureDetail(featureDetail *core.UIServiceTypes_FeatureDetail) string { + spec := featureDetail.GetSpec() + lines := []string{ + fmt.Sprintf("%s:\t%s", "Id", spec.GetId()), + fmt.Sprintf("%s:\t%s", "Entity", spec.GetEntity()), + fmt.Sprintf("%s:\t%s", "Owner", spec.GetOwner()), + fmt.Sprintf("%s:\t%s", "Description", spec.GetDescription()), + fmt.Sprintf("%s:\t%s", "ValueType", spec.GetValueType()), + fmt.Sprintf("%s:\t%s", "Uri", spec.GetUri()), + } + if dstores := spec.GetDataStores(); dstores != nil { + lines = append(lines, fmt.Sprintf("DataStores: ")) + if srv := dstores.GetServing(); srv != nil { + lines = append(lines, fmt.Sprintf(" %s:\t%s", "Serving", srv.GetId())) + } + if wh := dstores.GetWarehouse(); wh != nil { + lines = append(lines, fmt.Sprintf(" %s:\t%s", "Warehouse", wh.GetId())) + } + } + lines = append(lines, fmt.Sprintf("%s:\t%s", "Created", util.ParseTimestamp(*featureDetail.GetCreated()))) + lines = append(lines, fmt.Sprintf("%s:\t%s", "LastUpdated", util.ParseTimestamp(*featureDetail.GetLastUpdated()))) + if jobs := featureDetail.GetJobs(); len(jobs) > 0 { + lines = append(lines, "Related Jobs:") + for _, job := range jobs { + lines = append(lines, fmt.Sprintf("- %s", job)) + } + } + if tags := spec.GetTags(); len(tags) > 0 { + lines = append(lines, fmt.Sprintf("Tags: %s", strings.Join(tags, ","))) + } + out := strings.Join(lines, "\n") + fmt.Println(out) + return out +} + +// PrintEntityDetail prints the details about the feature. +// Prints and returns the resultant formatted string. +func PrintEntityDetail(entityDetail *core.UIServiceTypes_EntityDetail) string { + spec := entityDetail.GetSpec() + lines := []string{ + fmt.Sprintf("%s:\t%s", "Name", spec.GetName()), + fmt.Sprintf("%s:\t%s", "Description", spec.GetDescription()), + } + if tags := spec.GetTags(); len(tags) > 0 { + lines = append(lines, fmt.Sprintf("Tags: %s", strings.Join(tags, ","))) + } + lines = append(lines, fmt.Sprintf("%s:\t%s", "LastUpdated", util.ParseTimestamp(*entityDetail.GetLastUpdated()))) + lines = append(lines, "Related Jobs:") + for _, job := range entityDetail.GetJobs() { + lines = append(lines, fmt.Sprintf("- %s", job)) + } + out := strings.Join(lines, "\n") + fmt.Println(out) + return out +} + +// PrintStorageDetail prints the details about the feature. +// Prints and returns the resultant formatted string. +func PrintStorageDetail(storageDetail *core.UIServiceTypes_StorageDetail) string { + spec := storageDetail.GetSpec() + lines := []string{ + fmt.Sprintf("%s:\t%s", "Id", spec.GetId()), + fmt.Sprintf("%s:\t%s", "Type", spec.GetType()), + fmt.Sprintf("Options:"), + } + for k, v := range spec.GetOptions() { + lines = append(lines, fmt.Sprintf(" %s: %s", k, v)) + } + lines = append(lines, fmt.Sprintf("%s:\t%s", "LastUpdated", util.ParseTimestamp(*storageDetail.GetLastUpdated()))) + out := strings.Join(lines, "\n") + fmt.Println(out) + return out +} diff --git a/cli/feast/pkg/util/utils.go b/cli/feast/pkg/util/utils.go index 010cd6863e..a48ef6d741 100644 --- a/cli/feast/pkg/util/utils.go +++ b/cli/feast/pkg/util/utils.go @@ -32,3 +32,9 @@ func ParseAge(createdTimestamp timestamp.Timestamp) string { } return fmt.Sprintf("%dm", int(math.Floor(timeSinceCreation/float64(60)))) } + +// ParseTimestamp parses a given timestamp to a human readable format. +func ParseTimestamp(ts timestamp.Timestamp) string { + t := time.Unix(ts.GetSeconds(), int64(ts.GetNanos())) + return t.Format(time.RFC3339) +}