Skip to content

Commit

Permalink
Improve job describe output (#3459)
Browse files Browse the repository at this point in the history
This PR improves `GET /api/v1/orchestrator/jobs/:id` API and `bacalhau
job describe :id` CLI by including execution information in the output,
and printing a more human readable format instead on just spitting out
the job spec in YAML.

### API Improvements
`GET /api/v1/orchestrator/jobs/:id` now accepts an optional
`?include="executions,history"` parameters where callers can ask for the
job history and executions to be included in the response, instead of
having to do separate calls to `GET
/api/v1/orchestrator/jobs/:id/executions` and `GET
/api/v1/orchestrator/jobs/:id/history` APIs to get the full picture of
the job status.

### CLI Improvements
`bacalhau job describe :id` now prints the results in different sections
in a human readable format instead of the yaml spec as follows:

```
# run a job across three nodes where each print their time in nanos
bacalhau docker run --concurrency 3 ubuntu -- sh -c 'echo $(date +%s%N)'


# describe the job
→ bacalhau job describe e972a009-317e-45ec-b38e-3a40b5562141
ID            = e972a009-317e-45ec-b38e-3a40b5562141
Name          = e972a009-317e-45ec-b38e-3a40b5562141
Namespace     = 6af4ba5fa017d0b633e574e53fc773fee950f0d8bae9f7ff4e8f46d9e8e46cb1
Type          = batch
State         = Completed
Count         = 3
Created Time  = 2024-02-14 13:52:56
Modified Time = 2024-02-14 13:52:57
Version       = 1

Summary
Completed = 3

Executions
 ID          NODE ID  STATE      DESIRED  REV.  CREATED  MODIFIED  COMMENT
 e-0e2ff6bd  node-2   Completed  Stopped  6     7s ago   6s ago
 e-448d73d8  node-3   Completed  Stopped  6     7s ago   6s ago
 e-48cfe90d  node-1   Completed  Stopped  6     7s ago   6s ago

Standard Output
Execution e-48cfe90d:
1707918776915793876

Execution e-0e2ff6bd:
1707918776786937043

Execution e-448d73d8:
1707918776883765584

# Describe in yaml format
→ bacalhau job describe e972a009-317e-45ec-b38e-3a40b5562141 --output yaml
Executions:
  Executions:
  - AllocatedResources:
      Tasks: {}
    ComputeState:
      StateType: 7
    ...
Job:
  Constraints: []
  Count: 3
  CreateTime: 1707918776028710000
  ID: e972a009-317e-45ec-b38e-3a40b5562141
  Labels: {}
  Meta:
    bacalhau.org/client.id: 6af4ba5fa017d0b633e574e53fc773fee950f0d8bae9f7ff4e8f46d9e8e46cb1
    bacalhau.org/requester.id: node-0
  ModifyTime: 1707918777303266000
  Name: e972a009-317e-45ec-b38e-3a40b5562141
  Namespace: 6af4ba5fa017d0b633e574e53fc773fee950f0d8bae9f7ff4e8f46d9e8e46cb1
  Priority: 0
  Revision: 3
  State:
    StateType: Completed
  Tasks:
  - Engine:
      Params:
        Entrypoint: null
        EnvironmentVariables: []
        Image: ubuntu
        Parameters:
        - sh
        - -c
        - echo $(date +%s%N)
        WorkingDirectory: ""
      Type: docker
    Name: main
    Network:
      Type: None
    Publisher:
      Type: noop
    Resources: {}
    ResultPaths:
    - Name: outputs
      Path: /outputs
    Timeouts:
      ExecutionTimeout: 1800
  Type: batch
  Version: 1

```

Closes bacalhau-project/expanso-planning#402
  • Loading branch information
wdbaruni committed Feb 15, 2024
1 parent 490fe24 commit d736980
Show file tree
Hide file tree
Showing 8 changed files with 376 additions and 28 deletions.
122 changes: 118 additions & 4 deletions cmd/cli/job/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package job

import (
"fmt"
"time"

"github.com/bacalhau-project/bacalhau/pkg/lib/collections"
"github.com/bacalhau-project/bacalhau/pkg/models"
"github.com/bacalhau-project/bacalhau/pkg/util/idgen"
"github.com/spf13/cobra"
"k8s.io/kubectl/pkg/util/i18n"

Expand Down Expand Up @@ -38,7 +42,7 @@ type DescribeOptions struct {
// NewDescribeOptions returns initialized Options
func NewDescribeOptions() *DescribeOptions {
return &DescribeOptions{
OutputOpts: output.NonTabularOutputOptions{Format: output.YAMLFormat},
OutputOpts: output.NonTabularOutputOptions{},
}
}

Expand All @@ -60,13 +64,123 @@ func (o *DescribeOptions) run(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
jobID := args[0]
response, err := util.GetAPIClientV2().Jobs().Get(ctx, &apimodels.GetJobRequest{
JobID: jobID,
JobID: jobID,
Include: "executions",
})

if err != nil {
util.Fatal(cmd, fmt.Errorf("could not get job %s: %w", jobID, err), 1)
}

if err = output.OutputOneNonTabular(cmd, o.OutputOpts, response.Job); err != nil {
util.Fatal(cmd, fmt.Errorf("failed to write job %s: %w", jobID, err), 1)
if o.OutputOpts.Format != "" {
if err = output.OutputOneNonTabular(cmd, o.OutputOpts, response); err != nil {
util.Fatal(cmd, fmt.Errorf("failed to write job %s: %w", jobID, err), 1)
}
return
}

job := response.Job
var executions []*models.Execution
if response.Executions != nil {
// TODO: #520 rename Executions.Executions to Executions.Items
executions = response.Executions.Executions
}

o.printHeaderData(cmd, job)
o.printExecutionsSummary(cmd, executions)
if err = o.printExecutions(cmd, executions); err != nil {
util.Fatal(cmd, fmt.Errorf("failed to write job executions %s: %w", jobID, err), 1)
}
o.printOutputs(cmd, executions)
}

func (o *DescribeOptions) printHeaderData(cmd *cobra.Command, job *models.Job) {
var headerData = []collections.Pair[string, any]{
{Left: "ID", Right: job.ID},
{Left: "Name", Right: job.Name},
{Left: "Namespace", Right: job.Namespace},
{Left: "Type", Right: job.Type},
{Left: "State", Right: job.State.StateType},
{Left: "Message", Right: job.State.Message},
} // Job type specific data
if job.Type == models.JobTypeBatch || job.Type == models.JobTypeService {
headerData = append(headerData, collections.NewPair[string, any]("Count", job.Count))
}

// Additional data
headerData = append(headerData, []collections.Pair[string, any]{
{Left: "Created Time", Right: job.GetCreateTime().Format(time.DateTime)},
{Left: "Modified Time", Right: job.GetModifyTime().Format(time.DateTime)},
{Left: "Version", Right: job.Version},
}...)

output.KeyValue(cmd, headerData)
}

func (o *DescribeOptions) printExecutionsSummary(cmd *cobra.Command, executions []*models.Execution) {
// Summary of executions
var summaryPairs []collections.Pair[string, any]
summaryMap := map[models.ExecutionStateType]uint{}
for _, e := range executions {
summaryMap[e.ComputeState.StateType]++
}

for typ := models.ExecutionStateNew; typ < models.ExecutionStateCancelled; typ++ {
if summaryMap[typ] > 0 {
summaryPairs = append(summaryPairs, collections.NewPair[string, any](typ.String(), summaryMap[typ]))
}
}
output.Bold(cmd, "\nSummary\n")
output.KeyValue(cmd, summaryPairs)
}

func (o *DescribeOptions) printExecutions(cmd *cobra.Command, executions []*models.Execution) error {
// Executions table
tableOptions := output.OutputOptions{
Format: output.TableFormat,
NoStyle: true,
}
executionCols := []output.TableColumn[*models.Execution]{
executionColumnID,
executionColumnNodeID,
executionColumnState,
executionColumnDesired,
executionColumnRev,
executionColumnCreatedSince,
executionColumnModifiedSince,
executionColumnComment,
}
output.Bold(cmd, "\nExecutions\n")
return output.Output(cmd, executionCols, tableOptions, executions)
}

func (o *DescribeOptions) printOutputs(cmd *cobra.Command, executions []*models.Execution) {
outputs := make(map[string]string)
for _, e := range executions {
if e.RunOutput != nil {
separator := ""
if e.RunOutput.STDOUT != "" {
outputs[e.ID] = e.RunOutput.STDOUT
separator = "\n"
}
if e.RunOutput.STDERR != "" {
outputs[e.ID] += separator + e.RunOutput.STDERR
}
if e.RunOutput.StdoutTruncated || e.RunOutput.StderrTruncated {
outputs[e.ID] += "\n...\nOutput truncated"
}
}
}
if len(outputs) > 0 {
output.Bold(cmd, "\nStandard Output\n")
separator := ""
for id, out := range outputs {
if len(outputs) == 1 {
cmd.Print(out)
} else {
cmd.Printf("%sExecution %s:\n%s", separator, idgen.ShortID(id), out)
}
separator = "\n"
}
}
}
56 changes: 37 additions & 19 deletions cmd/cli/job/executions.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,39 +67,57 @@ func NewExecutionCmd() *cobra.Command {
return nodeCmd
}

var executionColumns = []output.TableColumn[*models.Execution]{
{
var (
executionColumnCreated = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Created", WidthMax: 8, WidthMaxEnforcer: output.ShortenTime},
Value: func(e *models.Execution) string { return e.GetCreateTime().Format(time.DateTime) },
},
{
}
executionColumnModified = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Modified", WidthMax: 8, WidthMaxEnforcer: output.ShortenTime},
Value: func(e *models.Execution) string { return e.GetModifyTime().Format(time.DateTime) },
},
{
}
executionColumnCreatedSince = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Created", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return output.Elapsed(e.GetCreateTime()) },
}
executionColumnModifiedSince = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Modified", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return output.Elapsed(e.GetModifyTime()) },
}
executionColumnID = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "ID", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return idgen.ShortID(e.ID) },
},
{
}
executionColumnNodeID = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Node ID", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return idgen.ShortID(e.NodeID) },
},
{
}
executionColumnRev = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Rev.", WidthMax: 4, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return strconv.FormatUint(e.Revision, 10) },
},
{
ColumnConfig: table.ColumnConfig{Name: "Compute\nState", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
}
executionColumnState = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "State", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return e.ComputeState.StateType.String() },
},
{
ColumnConfig: table.ColumnConfig{Name: "Desired\nState", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
}
executionColumnDesired = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Desired", WidthMax: 10, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return e.DesiredState.StateType.String() },
},
{
}
executionColumnComment = output.TableColumn[*models.Execution]{
ColumnConfig: table.ColumnConfig{Name: "Comment", WidthMax: 40, WidthMaxEnforcer: text.WrapText},
Value: func(e *models.Execution) string { return e.ComputeState.Message },
},
}
)

var executionColumns = []output.TableColumn[*models.Execution]{
executionColumnCreated,
executionColumnModified,
executionColumnID,
executionColumnNodeID,
executionColumnRev,
executionColumnState,
executionColumnDesired,
}

func (o *ExecutionOptions) run(cmd *cobra.Command, args []string) {
Expand Down
42 changes: 42 additions & 0 deletions cmd/util/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"

"github.com/bacalhau-project/bacalhau/pkg/lib/collections"
"github.com/ghodss/yaml"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/samber/lo"
Expand All @@ -19,6 +20,11 @@ const (
YAMLFormat OutputFormat = "yaml"
)

const (
bold = "\033[1m"
reset = "\033[0m"
)

var AllFormats = append([]OutputFormat{TableFormat, CSVFormat}, NonTabularFormats...)
var NonTabularFormats = []OutputFormat{JSONFormat, YAMLFormat}

Expand Down Expand Up @@ -105,6 +111,42 @@ func OutputOne[T any](cmd *cobra.Command, columns []TableColumn[T], options Outp
}
}

// KeyValue prints a list of key-value pairs in a human-readable format
// with the keys aligned.
// Example:
//
// KeyValue(cmd, []collections.Pair[string, any]{
// collections.NewPair("Name", "John"),
// collections.NewPair("Age", 30),
// })
//
// Output:
//
// Name = John
// Age = 30
func KeyValue(cmd *cobra.Command, data []collections.Pair[string, any]) {
// Find the longest key to align values nicely
maxKeyLength := 0
for _, pair := range data {
if len(pair.Left) > maxKeyLength {
maxKeyLength = len(pair.Left)
}
}

// Print the key-value pairs with alignment
for _, pair := range data {
if fmt.Sprintf("%v", pair.Right) == "" {
continue
}
cmd.Printf("%-*s = %v\n", maxKeyLength, pair.Left, pair.Right)
}
}

// Bold prints the given string in bold
func Bold(cmd *cobra.Command, s string) {
cmd.Print(bold + s + reset)
}

func OutputOneNonTabular[T any](cmd *cobra.Command, options NonTabularOutputOptions, item T) error {
switch options.Format {
case JSONFormat:
Expand Down
49 changes: 48 additions & 1 deletion cmd/util/output/utils.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
package output

import "time"
import (
"fmt"
"time"
)

const (
second = 1
minute = 60 * second
hour = 60 * minute
day = 24 * hour
)

func ShortenTime(formattedTime string, maxLen int) string {
if len(formattedTime) > maxLen {
Expand All @@ -13,3 +23,40 @@ func ShortenTime(formattedTime string, maxLen int) string {

return formattedTime
}

// Elapsed returns a human-readable string representing the time elapsed since t
// e.g. "3d" for 3 days, "2h" for 2 hours, "5m" for 5 minutes, "10s" for 10 seconds
func Elapsed(t time.Time) string {
d := time.Since(t)
totalSeconds := int(d.Seconds())

days := totalSeconds / day
hours := (totalSeconds % day) / hour
minutes := (totalSeconds % hour) / minute
seconds := totalSeconds % minute

var result string
if days > 0 {
if hours > 0 {
result = fmt.Sprintf("%dd%dh", days, hours)
} else {
result = fmt.Sprintf("%dd", days)
}
} else if hours > 0 {
if minutes > 0 {
result = fmt.Sprintf("%dh%dm", hours, minutes)
} else {
result = fmt.Sprintf("%dh", hours)
}
} else if minutes > 0 {
if seconds > 0 {
result = fmt.Sprintf("%dm%ds", minutes, seconds)
} else {
result = fmt.Sprintf("%dm", minutes)
}
} else {
result = fmt.Sprintf("%ds", seconds)
}

return result + " ago"
}
19 changes: 19 additions & 0 deletions pkg/lib/collections/pair.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package collections

import "fmt"

// Pair is a generic structure that holds two values of any type.
type Pair[L any, R any] struct {
Left L
Right R
}

// NewPair creates a new Pair with the given values.
func NewPair[L any, R any](left L, right R) Pair[L, R] {
return Pair[L, R]{Left: left, Right: right}
}

// String returns a string representation of the Pair.
func (p Pair[L, R]) String() string {
return fmt.Sprintf("(%v, %v)", p.Left, p.Right)
}

0 comments on commit d736980

Please sign in to comment.