github.com/moby/docker@v26.1.3+incompatible/pkg/jsonmessage/jsonmessage.go (about)

     1  package jsonmessage // import "github.com/docker/docker/pkg/jsonmessage"
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io"
     7  	"strings"
     8  	"time"
     9  
    10  	units "github.com/docker/go-units"
    11  	"github.com/moby/term"
    12  	"github.com/morikuni/aec"
    13  )
    14  
    15  // RFC3339NanoFixed is time.RFC3339Nano with nanoseconds padded using zeros to
    16  // ensure the formatted time isalways the same number of characters.
    17  const RFC3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
    18  
    19  // JSONError wraps a concrete Code and Message, Code is
    20  // an integer error code, Message is the error message.
    21  type JSONError struct {
    22  	Code    int    `json:"code,omitempty"`
    23  	Message string `json:"message,omitempty"`
    24  }
    25  
    26  func (e *JSONError) Error() string {
    27  	return e.Message
    28  }
    29  
    30  // JSONProgress describes a progress message in a JSON stream.
    31  type JSONProgress struct {
    32  	// Current is the current status and value of the progress made towards Total.
    33  	Current int64 `json:"current,omitempty"`
    34  	// Total is the end value describing when we made 100% progress for an operation.
    35  	Total int64 `json:"total,omitempty"`
    36  	// Start is the initial value for the operation.
    37  	Start int64 `json:"start,omitempty"`
    38  	// HideCounts. if true, hides the progress count indicator (xB/yB).
    39  	HideCounts bool `json:"hidecounts,omitempty"`
    40  	// Units is the unit to print for progress. It defaults to "bytes" if empty.
    41  	Units string `json:"units,omitempty"`
    42  
    43  	// terminalFd is the fd of the current terminal, if any. It is used
    44  	// to get the terminal width.
    45  	terminalFd uintptr
    46  
    47  	// nowFunc is used to override the current time in tests.
    48  	nowFunc func() time.Time
    49  
    50  	// winSize is used to override the terminal width in tests.
    51  	winSize int
    52  }
    53  
    54  func (p *JSONProgress) String() string {
    55  	var (
    56  		width       = p.width()
    57  		pbBox       string
    58  		numbersBox  string
    59  		timeLeftBox string
    60  	)
    61  	if p.Current <= 0 && p.Total <= 0 {
    62  		return ""
    63  	}
    64  	if p.Total <= 0 {
    65  		switch p.Units {
    66  		case "":
    67  			return fmt.Sprintf("%8v", units.HumanSize(float64(p.Current)))
    68  		default:
    69  			return fmt.Sprintf("%d %s", p.Current, p.Units)
    70  		}
    71  	}
    72  
    73  	percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
    74  	if percentage > 50 {
    75  		percentage = 50
    76  	}
    77  	if width > 110 {
    78  		// this number can't be negative gh#7136
    79  		numSpaces := 0
    80  		if 50-percentage > 0 {
    81  			numSpaces = 50 - percentage
    82  		}
    83  		pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
    84  	}
    85  
    86  	switch {
    87  	case p.HideCounts:
    88  	case p.Units == "": // no units, use bytes
    89  		current := units.HumanSize(float64(p.Current))
    90  		total := units.HumanSize(float64(p.Total))
    91  
    92  		numbersBox = fmt.Sprintf("%8v/%v", current, total)
    93  
    94  		if p.Current > p.Total {
    95  			// remove total display if the reported current is wonky.
    96  			numbersBox = fmt.Sprintf("%8v", current)
    97  		}
    98  	default:
    99  		numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
   100  
   101  		if p.Current > p.Total {
   102  			// remove total display if the reported current is wonky.
   103  			numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
   104  		}
   105  	}
   106  
   107  	if p.Current > 0 && p.Start > 0 && percentage < 50 {
   108  		fromStart := p.now().Sub(time.Unix(p.Start, 0))
   109  		perEntry := fromStart / time.Duration(p.Current)
   110  		left := time.Duration(p.Total-p.Current) * perEntry
   111  		left = (left / time.Second) * time.Second
   112  
   113  		if width > 50 {
   114  			timeLeftBox = " " + left.String()
   115  		}
   116  	}
   117  	return pbBox + numbersBox + timeLeftBox
   118  }
   119  
   120  // now returns the current time in UTC, but can be overridden in tests
   121  // by setting JSONProgress.nowFunc to a custom function.
   122  func (p *JSONProgress) now() time.Time {
   123  	if p.nowFunc != nil {
   124  		return p.nowFunc()
   125  	}
   126  	return time.Now().UTC()
   127  }
   128  
   129  // width returns the current terminal's width, but can be overridden
   130  // in tests by setting JSONProgress.winSize to a non-zero value.
   131  func (p *JSONProgress) width() int {
   132  	if p.winSize != 0 {
   133  		return p.winSize
   134  	}
   135  	ws, err := term.GetWinsize(p.terminalFd)
   136  	if err == nil {
   137  		return int(ws.Width)
   138  	}
   139  	return 200
   140  }
   141  
   142  // JSONMessage defines a message struct. It describes
   143  // the created time, where it from, status, ID of the
   144  // message. It's used for docker events.
   145  type JSONMessage struct {
   146  	Stream          string        `json:"stream,omitempty"`
   147  	Status          string        `json:"status,omitempty"`
   148  	Progress        *JSONProgress `json:"progressDetail,omitempty"`
   149  	ProgressMessage string        `json:"progress,omitempty"` // deprecated
   150  	ID              string        `json:"id,omitempty"`
   151  	From            string        `json:"from,omitempty"`
   152  	Time            int64         `json:"time,omitempty"`
   153  	TimeNano        int64         `json:"timeNano,omitempty"`
   154  	Error           *JSONError    `json:"errorDetail,omitempty"`
   155  	ErrorMessage    string        `json:"error,omitempty"` // deprecated
   156  	// Aux contains out-of-band data, such as digests for push signing and image id after building.
   157  	Aux *json.RawMessage `json:"aux,omitempty"`
   158  }
   159  
   160  func clearLine(out io.Writer) {
   161  	eraseMode := aec.EraseModes.All
   162  	cl := aec.EraseLine(eraseMode)
   163  	fmt.Fprint(out, cl)
   164  }
   165  
   166  func cursorUp(out io.Writer, l uint) {
   167  	fmt.Fprint(out, aec.Up(l))
   168  }
   169  
   170  func cursorDown(out io.Writer, l uint) {
   171  	fmt.Fprint(out, aec.Down(l))
   172  }
   173  
   174  // Display prints the JSONMessage to out. If isTerminal is true, it erases
   175  // the entire current line when displaying the progressbar. It returns an
   176  // error if the [JSONMessage.Error] field is non-nil.
   177  func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
   178  	if jm.Error != nil {
   179  		return jm.Error
   180  	}
   181  	var endl string
   182  	if isTerminal && jm.Stream == "" && jm.Progress != nil {
   183  		clearLine(out)
   184  		endl = "\r"
   185  		fmt.Fprint(out, endl)
   186  	} else if jm.Progress != nil && jm.Progress.String() != "" { // disable progressbar in non-terminal
   187  		return nil
   188  	}
   189  	if jm.TimeNano != 0 {
   190  		fmt.Fprintf(out, "%s ", time.Unix(0, jm.TimeNano).Format(RFC3339NanoFixed))
   191  	} else if jm.Time != 0 {
   192  		fmt.Fprintf(out, "%s ", time.Unix(jm.Time, 0).Format(RFC3339NanoFixed))
   193  	}
   194  	if jm.ID != "" {
   195  		fmt.Fprintf(out, "%s: ", jm.ID)
   196  	}
   197  	if jm.From != "" {
   198  		fmt.Fprintf(out, "(from %s) ", jm.From)
   199  	}
   200  	if jm.Progress != nil && isTerminal {
   201  		fmt.Fprintf(out, "%s %s%s", jm.Status, jm.Progress.String(), endl)
   202  	} else if jm.ProgressMessage != "" { // deprecated
   203  		fmt.Fprintf(out, "%s %s%s", jm.Status, jm.ProgressMessage, endl)
   204  	} else if jm.Stream != "" {
   205  		fmt.Fprintf(out, "%s%s", jm.Stream, endl)
   206  	} else {
   207  		fmt.Fprintf(out, "%s%s\n", jm.Status, endl)
   208  	}
   209  	return nil
   210  }
   211  
   212  // DisplayJSONMessagesStream reads a JSON message stream from in, and writes
   213  // each [JSONMessage] to out. It returns an error if an invalid JSONMessage
   214  // is received, or if a JSONMessage containers a non-zero [JSONMessage.Error].
   215  //
   216  // Presentation of the JSONMessage depends on whether a terminal is attached,
   217  // and on the terminal width. Progress bars ([JSONProgress]) are suppressed
   218  // on narrower terminals (< 110 characters).
   219  //
   220  //   - isTerminal describes if out is a terminal, in which case it prints
   221  //     a newline ("\n") at the end of each line and moves the cursor while
   222  //     displaying.
   223  //   - terminalFd is the fd of the current terminal (if any), and used
   224  //     to get the terminal width.
   225  //   - auxCallback allows handling the [JSONMessage.Aux] field. It is
   226  //     called if a JSONMessage contains an Aux field, in which case
   227  //     DisplayJSONMessagesStream does not present the JSONMessage.
   228  func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr, isTerminal bool, auxCallback func(JSONMessage)) error {
   229  	var (
   230  		dec = json.NewDecoder(in)
   231  		ids = make(map[string]uint)
   232  	)
   233  
   234  	for {
   235  		var diff uint
   236  		var jm JSONMessage
   237  		if err := dec.Decode(&jm); err != nil {
   238  			if err == io.EOF {
   239  				break
   240  			}
   241  			return err
   242  		}
   243  
   244  		if jm.Aux != nil {
   245  			if auxCallback != nil {
   246  				auxCallback(jm)
   247  			}
   248  			continue
   249  		}
   250  
   251  		if jm.Progress != nil {
   252  			jm.Progress.terminalFd = terminalFd
   253  		}
   254  		if jm.ID != "" && (jm.Progress != nil || jm.ProgressMessage != "") {
   255  			line, ok := ids[jm.ID]
   256  			if !ok {
   257  				// NOTE: This approach of using len(id) to
   258  				// figure out the number of lines of history
   259  				// only works as long as we clear the history
   260  				// when we output something that's not
   261  				// accounted for in the map, such as a line
   262  				// with no ID.
   263  				line = uint(len(ids))
   264  				ids[jm.ID] = line
   265  				if isTerminal {
   266  					fmt.Fprintf(out, "\n")
   267  				}
   268  			}
   269  			diff = uint(len(ids)) - line
   270  			if isTerminal {
   271  				cursorUp(out, diff)
   272  			}
   273  		} else {
   274  			// When outputting something that isn't progress
   275  			// output, clear the history of previous lines. We
   276  			// don't want progress entries from some previous
   277  			// operation to be updated (for example, pull -a
   278  			// with multiple tags).
   279  			ids = make(map[string]uint)
   280  		}
   281  		err := jm.Display(out, isTerminal)
   282  		if jm.ID != "" && isTerminal {
   283  			cursorDown(out, diff)
   284  		}
   285  		if err != nil {
   286  			return err
   287  		}
   288  	}
   289  	return nil
   290  }
   291  
   292  // Stream is an io.Writer for output with utilities to get the output's file
   293  // descriptor and to detect wether it's a terminal.
   294  //
   295  // it is subset of the streams.Out type in
   296  // https://pkg.go.dev/github.com/docker/cli@v20.10.17+incompatible/cli/streams#Out
   297  type Stream interface {
   298  	io.Writer
   299  	FD() uintptr
   300  	IsTerminal() bool
   301  }
   302  
   303  // DisplayJSONMessagesToStream prints json messages to the output Stream. It is
   304  // used by the Docker CLI to print JSONMessage streams.
   305  func DisplayJSONMessagesToStream(in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
   306  	return DisplayJSONMessagesStream(in, stream, stream.FD(), stream.IsTerminal(), auxCallback)
   307  }