// Copyright 2016-2018, Pulumi Corporation. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package display // forked from: https://github.com/moby/moby/blob/master/pkg/jsonmessage/jsonmessage.go // so we can customize parts of the display of our progress messages import ( "fmt" "io" "os" "slices" "strconv" "unicode/utf8" "github.com/pulumi/pulumi/pkg/v3/backend/display/internal/terminal" "github.com/pulumi/pulumi/pkg/v3/engine" "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "golang.org/x/exp/maps" ) // Progress describes a message we want to show in the display. There are two types of messages, // simple 'Messages' which just get printed out as a single uninterpreted line, and 'Actions' which // are placed and updated in the progress-grid based on their ID. Messages do not need an ID, while // Actions must have an ID. type Progress struct { ID string Message string Action string } func makeMessageProgress(message string) Progress { return Progress{Message: message} } func makeActionProgress(id string, action string) Progress { contract.Assertf(id != "", "id must be non empty for action %s", action) contract.Assertf(action != "", "action must be non empty") return Progress{ID: id, Action: action} } // Display displays the Progress to `out`. `termInfo` is non-nil if `out` is a terminal. func (jm *Progress) Display(out io.Writer, termInfo terminal.Info) { var emitCr bool if termInfo != nil && /*jm.Stream == "" &&*/ jm.Action != "" { termInfo.ClearLine(out) emitCr = true termInfo.CarriageReturn(out) } if jm.Action != "" && termInfo != nil { fmt.Fprint(out, jm.Action) if emitCr { termInfo.CarriageReturn(out) } } else { var msg string if jm.Action != "" { msg = jm.Action } else { msg = jm.Message } fmt.Fprint(out, msg) if emitCr { termInfo.CarriageReturn(out) } fmt.Fprint(out, "\n") } } type messageRenderer struct { opts Options isInteractive bool display *ProgressDisplay terminal terminal.Terminal terminalWidth int terminalHeight int // A spinner to use to show that we're still doing work even when no output has been // printed to the console in a while. nonInteractiveSpinner cmdutil.Spinner progressOutput chan<- Progress closed <-chan bool // Cache of lines we've already printed. We don't print a progress message again if it hasn't // changed between the last time we printed and now. printedProgressCache map[string]Progress } func newInteractiveMessageRenderer(term terminal.Terminal, opts Options) progressRenderer { r := newMessageRenderer(term, opts, true) r.terminal = term var err error r.terminalWidth, r.terminalHeight, err = term.Size() contract.IgnoreError(err) return r } func newNonInteractiveRenderer(stdout io.Writer, op string, opts Options) progressRenderer { spinner, ticker := cmdutil.NewSpinnerAndTicker( fmt.Sprintf("%s%s...", cmdutil.EmojiOr("✨ ", "@ "), op), nil, opts.Color, 1 /*timesPerSecond*/, opts.SuppressProgress) ticker.Stop() r := newMessageRenderer(stdout, opts, false) r.nonInteractiveSpinner = spinner return r } func newMessageRenderer(out io.Writer, opts Options, isInteractive bool) *messageRenderer { progressOutput, closed := make(chan Progress), make(chan bool) go func() { ShowProgressOutput(progressOutput, out, isInteractive) close(closed) }() return &messageRenderer{ opts: opts, isInteractive: isInteractive, progressOutput: progressOutput, closed: closed, printedProgressCache: make(map[string]Progress), } } func (r *messageRenderer) Close() error { close(r.progressOutput) <-r.closed return nil } func (r *messageRenderer) initializeDisplay(display *ProgressDisplay) { r.display = display } // Converts the colorization tags in a progress message and then actually writes the progress // message to the output stream. This should be the only place in this file where we actually // process colorization tags. func (r *messageRenderer) colorizeAndWriteProgress(progress Progress) { if progress.Message != "" { progress.Message = r.opts.Color.Colorize(progress.Message) } if progress.Action != "" { progress.Action = r.opts.Color.Colorize(progress.Action) } if progress.ID != "" { // don't repeat the same output if there is no difference between the last time we // printed it and now. lastProgress, has := r.printedProgressCache[progress.ID] if has && lastProgress.Message == progress.Message && lastProgress.Action == progress.Action { return } r.printedProgressCache[progress.ID] = progress } if !r.isInteractive { // We're about to display something. Reset our spinner so that it will go on the next line. r.nonInteractiveSpinner.Reset() } r.progressOutput <- progress } func (r *messageRenderer) writeSimpleMessage(msg string) { r.colorizeAndWriteProgress(makeMessageProgress(msg)) } func (r *messageRenderer) println(line string) { r.writeSimpleMessage(line) } func (r *messageRenderer) tick() { if r.isInteractive { r.render(false) } else { // Update the spinner to let the user know that that work is still happening. r.nonInteractiveSpinner.Tick() } } func (r *messageRenderer) renderRow(id string, colorizedColumns []string, maxColumnLengths []int, ) { row := renderRow(colorizedColumns, maxColumnLengths) if r.isInteractive { // Ensure we don't go past the end of the terminal. Note: this is made complex due to // msgWithColors having the color code information embedded with it. So we need to get // the right substring of it, assuming that embedded colors are just markup and do not // actually contribute to the length maxRowLength := r.terminalWidth - 1 if maxRowLength < 0 { maxRowLength = 0 } row = colors.TrimColorizedString(row, maxRowLength) } if row != "" { if r.isInteractive { r.colorizeAndWriteProgress(makeActionProgress(id, row)) } else { r.writeSimpleMessage(row) } } } func (r *messageRenderer) rowUpdated(row Row) { if r.isInteractive { // if we're in a terminal, then refresh everything so that all our columns line up r.render(false) } else if !row.HideRowIfUnnecessary() { // otherwise, just print out this single row. colorizedColumns := row.ColorizedColumns() colorizedColumns[r.display.suffixColumn] += row.ColorizedSuffix() r.renderRow("", colorizedColumns, nil) } } func (r *messageRenderer) systemMessage(payload engine.StdoutEventPayload) { if r.isInteractive { // if we're in a terminal, then refresh everything. The system events will come after // all the normal rows r.render(false) } else { // otherwise, in a non-terminal, just print out the actual event. r.writeSimpleMessage(renderStdoutColorEvent(payload, r.display.opts)) } } func (r *messageRenderer) progress(payload engine.ProgressEventPayload, first bool) { if r.isInteractive { r.render(false) } else if payload.Done { r.writeSimpleMessage(payload.Message + ": done") } else if first { r.writeSimpleMessage(payload.Message + ": starting") } } func (r *messageRenderer) done() { if r.isInteractive { r.render(false) } } func (r *messageRenderer) render(done bool) { if !r.isInteractive || r.display.headerRow == nil { return } // make sure our stored dimension info is up to date r.updateTerminalDimensions() rootNodes := r.display.generateTreeNodes() rootNodes = r.display.filterOutUnnecessaryNodesAndSetDisplayTimes(rootNodes) sortNodes(rootNodes) r.display.addIndentations(rootNodes, true /*isRoot*/, "") maxSuffixLength := 0 for _, v := range r.display.suffixesArray { runeCount := utf8.RuneCountInString(v) if runeCount > maxSuffixLength { maxSuffixLength = runeCount } } var rows [][]string var maxColumnLengths []int r.display.convertNodesToRows(rootNodes, maxSuffixLength, &rows, &maxColumnLengths) removeInfoColumnIfUnneeded(rows) for i, row := range rows { r.renderRow(strconv.Itoa(i), row, maxColumnLengths) } systemID := len(rows) for i, payload := range r.display.systemEventPayloads { msg := payload.Color.Colorize(payload.Message) lines := splitIntoDisplayableLines(msg) if len(lines) == 0 { continue } if i == 0 { r.colorizeAndWriteProgress(makeActionProgress( strconv.Itoa(systemID), " ")) systemID++ r.colorizeAndWriteProgress(makeActionProgress( strconv.Itoa(systemID), colors.Yellow+"System Messages"+colors.Reset)) systemID++ } for _, line := range lines { r.colorizeAndWriteProgress(makeActionProgress( strconv.Itoa(systemID), " "+line)) systemID++ } } if len(r.display.progressEventPayloads) > 0 { // Render progress events into the JSON message stream using ASCII // progress bars to be safe. keys := maps.Keys(r.display.progressEventPayloads) slices.Sort(keys) for i, key := range keys { if i == 0 { r.colorizeAndWriteProgress(makeActionProgress( strconv.Itoa(systemID), colors.Yellow+"Downloads"+colors.Reset)) } payload := r.display.progressEventPayloads[key] rendered := renderProgress(renderASCIIProgressBar, r.terminalWidth, payload) r.colorizeAndWriteProgress(makeActionProgress(payload.ID, rendered)) } } if done { r.println("") } } // Ensure our stored dimension info is up to date. func (r *messageRenderer) updateTerminalDimensions() { currentTerminalWidth, currentTerminalHeight, err := r.terminal.Size() contract.IgnoreError(err) if currentTerminalWidth != r.terminalWidth || currentTerminalHeight != r.terminalHeight { r.terminalWidth = currentTerminalWidth r.terminalHeight = currentTerminalHeight // also clear our display cache as we want to reprint all lines. r.printedProgressCache = make(map[string]Progress) } } // ShowProgressOutput displays a progress stream from `in` to `out`, `isInteractive` describes if // `out` is a terminal. If this is the case, it will print `\n` at the end of each line and move the // cursor while displaying. func ShowProgressOutput(in <-chan Progress, out io.Writer, isInteractive bool) { ids := make(map[string]int) var info terminal.Info if isInteractive { term := os.Getenv("TERM") if term == "" { term = "vt102" } info = terminal.OpenInfo(term) } for jm := range in { diff := 0 if jm.Action != "" { if jm.ID == "" { contract.Failf("Must have an ID if we have an action! %s", jm.Action) } line, ok := ids[jm.ID] if !ok { // NOTE: This approach of using len(id) to // figure out the number of lines of history // only works as long as we clear the history // when we output something that's not // accounted for in the map, such as a line // with no ID. line = len(ids) ids[jm.ID] = line if info != nil { fmt.Fprintf(out, "\n") } } diff = len(ids) - line if info != nil { info.CursorUp(out, diff) } } else { // When outputting something that isn't progress // output, clear the history of previous lines. We // don't want progress entries from some previous // operation to be updated (for example, pull -a // with multiple tags). ids = make(map[string]int) } jm.Display(out, info) if jm.Action != "" && info != nil { info.CursorDown(out, diff) } } }