mirror of https://github.com/pulumi/pulumi.git
421 lines
12 KiB
Go
421 lines
12 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|