pkg/jsonmessage: Use terminfo rather than open coding ANSI escape codes

Although our use of ANSI codes here is rather simple it is generally good
practice to use terminfo in order to be portable to different terminal
emulators.

Vendor github.com/Nvveen/Gotty (actually my fork with a fix, see
https://github.com/Nvveen/Gotty/pull/1) and use that to parse the terminfo
files.

Note that "\e]2K" (clear entire line) is not covered by terminfo. We can
achieve the same end by first clearing from begining of line to cursor
(el1="\e]1K") and then clearing from cursor to end of line (el="\e]k").

Test suite has been updated and forces (either directly or by setting $TERM to
something highly unlikely to exist) the use of the non-terminfo fallbacks which
retains the same output behaviour as previously. This is preferable even to
relying on a well-known and relatively static terminfo (like vt102) since even
that in principal might have different terminfo encodings.

In case terminfo is not available at all for $TERM or doesn't expose the
specific capabilities which we use then fall back to the previous manual
escapes, with the exception that we avoid "\e]2K" as discussed above.

Tested with a manual docker pull with rxvt-unicode ($TERM=rxvt-unicode), xterm
($TERM=xterm), mlterm ($TERM=mlterm) and aterm ($TERM=kterm).

Signed-off-by: Ian Campbell <ian.campbell@docker.com>
Upstream-commit: f02221a7941948017df68db8fd9a5de7f19453bf
Component: engine
This commit is contained in:
Ian Campbell
2016-11-11 11:40:53 +00:00
parent fd24308e6e
commit d23f15cd3f
8 changed files with 1266 additions and 18 deletions
@@ -4,9 +4,12 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/Nvveen/Gotty"
"github.com/docker/docker/pkg/jsonlog"
"github.com/docker/docker/pkg/term"
"github.com/docker/go-units"
@@ -106,10 +109,60 @@ type JSONMessage struct {
Aux *json.RawMessage `json:"aux,omitempty"`
}
// Display displays the JSONMessage to `out`. `isTerminal` describes if `out`
/* Satisfied by gotty.TermInfo as well as noTermInfo from below */
type termInfo interface {
Parse(attr string, params ...interface{}) (string, error)
}
type noTermInfo struct{} // canary used when no terminfo.
func (ti *noTermInfo) Parse(attr string, params ...interface{}) (string, error) {
return "", fmt.Errorf("noTermInfo")
}
func clearLine(out io.Writer, ti termInfo) {
// el2 (clear whole line) is not exposed by terminfo.
// First clear line from beginning to cursor
if attr, err := ti.Parse("el1"); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "%c[1K", 27)
}
// Then clear line from cursor to end
if attr, err := ti.Parse("el"); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "%c[K", 27)
}
}
func cursorUp(out io.Writer, ti termInfo, l int) {
if l == 0 { // Should never be the case, but be tolerant
return
}
if attr, err := ti.Parse("cuu", l); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "%c[%dA", 27, l)
}
}
func cursorDown(out io.Writer, ti termInfo, l int) {
if l == 0 { // Should never be the case, but be tolerant
return
}
if attr, err := ti.Parse("cud", l); err == nil {
fmt.Fprintf(out, "%s", attr)
} else {
fmt.Fprintf(out, "%c[%dB", 27, l)
}
}
// Display displays the JSONMessage to `out`. `termInfo` is non-nil if `out`
// is a terminal. If this is the case, it will erase the entire current line
// when displaying the progressbar.
func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
func (jm *JSONMessage) Display(out io.Writer, termInfo termInfo) error {
if jm.Error != nil {
if jm.Error.Code == 401 {
return fmt.Errorf("Authentication is required.")
@@ -117,10 +170,10 @@ func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
return jm.Error
}
var endl string
if isTerminal && jm.Stream == "" && jm.Progress != nil {
// <ESC>[2K = erase entire current line
fmt.Fprintf(out, "%c[2K\r", 27)
if termInfo != nil && jm.Stream == "" && jm.Progress != nil {
clearLine(out, termInfo)
endl = "\r"
fmt.Fprintf(out, endl)
} else if jm.Progress != nil && jm.Progress.String() != "" { //disable progressbar in non-terminal
return nil
}
@@ -135,7 +188,7 @@ func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
if jm.From != "" {
fmt.Fprintf(out, "(from %s) ", jm.From)
}
if jm.Progress != nil && isTerminal {
if jm.Progress != nil && termInfo != nil {
fmt.Fprintf(out, "%s %s%s", jm.Status, jm.Progress.String(), endl)
} else if jm.ProgressMessage != "" { //deprecated
fmt.Fprintf(out, "%s %s%s", jm.Status, jm.ProgressMessage, endl)
@@ -155,6 +208,21 @@ func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr,
dec = json.NewDecoder(in)
ids = make(map[string]int)
)
var termInfo termInfo
if isTerminal {
term := os.Getenv("TERM")
if term == "" {
term = "vt102"
}
var err error
if termInfo, err = gotty.OpenTermInfo(term); err != nil {
termInfo = &noTermInfo{}
}
}
for {
diff := 0
var jm JSONMessage
@@ -186,13 +254,13 @@ func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr,
// with no ID.
line = len(ids)
ids[jm.ID] = line
if isTerminal {
if termInfo != nil {
fmt.Fprintf(out, "\n")
}
}
diff = len(ids) - line
if isTerminal && diff > 0 {
fmt.Fprintf(out, "%c[%dA", 27, diff)
if termInfo != nil {
cursorUp(out, termInfo, diff)
}
} else {
// When outputting something that isn't progress
@@ -202,9 +270,9 @@ func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr,
// with multiple tags).
ids = make(map[string]int)
}
err := jm.Display(out, isTerminal)
if jm.ID != "" && isTerminal && diff > 0 {
fmt.Fprintf(out, "%c[%dB", 27, diff)
err := jm.Display(out, termInfo)
if jm.ID != "" && termInfo != nil {
cursorDown(out, termInfo, diff)
}
if err != nil {
return err
@@ -3,6 +3,7 @@ package jsonmessage
import (
"bytes"
"fmt"
"os"
"strings"
"testing"
"time"
@@ -132,7 +133,7 @@ func TestJSONMessageDisplay(t *testing.T) {
Progress: &JSONProgress{Current: 1},
}: {
"",
fmt.Sprintf("%c[2K\rstatus 1 B\r", 27),
fmt.Sprintf("%c[1K%c[K\rstatus 1 B\r", 27, 27),
},
}
@@ -140,7 +141,7 @@ func TestJSONMessageDisplay(t *testing.T) {
for jsonMessage, expectedMessages := range messages {
// Without terminal
data := bytes.NewBuffer([]byte{})
if err := jsonMessage.Display(data, false); err != nil {
if err := jsonMessage.Display(data, nil); err != nil {
t.Fatal(err)
}
if data.String() != expectedMessages[0] {
@@ -148,7 +149,7 @@ func TestJSONMessageDisplay(t *testing.T) {
}
// With terminal
data = bytes.NewBuffer([]byte{})
if err := jsonMessage.Display(data, true); err != nil {
if err := jsonMessage.Display(data, &noTermInfo{}); err != nil {
t.Fatal(err)
}
if data.String() != expectedMessages[1] {
@@ -162,13 +163,13 @@ func TestJSONMessageDisplayWithJSONError(t *testing.T) {
data := bytes.NewBuffer([]byte{})
jsonMessage := JSONMessage{Error: &JSONError{404, "Can't find it"}}
err := jsonMessage.Display(data, true)
err := jsonMessage.Display(data, &noTermInfo{})
if err == nil || err.Error() != "Can't find it" {
t.Fatalf("Expected a JSONError 404, got %q", err)
}
jsonMessage = JSONMessage{Error: &JSONError{401, "Anything"}}
err = jsonMessage.Display(data, true)
err = jsonMessage.Display(data, &noTermInfo{})
if err == nil || err.Error() != "Authentication is required." {
t.Fatalf("Expected an error \"Authentication is required.\", got %q", err)
}
@@ -215,9 +216,15 @@ func TestDisplayJSONMessagesStream(t *testing.T) {
// With progressDetail
"{ \"id\": \"ID\", \"status\": \"status\", \"progressDetail\": { \"Current\": 1} }": {
"", // progressbar is disabled in non-terminal
fmt.Sprintf("\n%c[%dA%c[2K\rID: status 1 B\r%c[%dB", 27, 1, 27, 27, 1),
fmt.Sprintf("\n%c[%dA%c[1K%c[K\rID: status 1 B\r%c[%dB", 27, 1, 27, 27, 27, 1),
},
}
// Use $TERM which is unlikely to exist, forcing DisplayJSONMessageStream to
// (hopefully) use &noTermInfo.
origTerm := os.Getenv("TERM")
os.Setenv("TERM", "xyzzy-non-existent-terminfo")
for jsonMessage, expectedMessages := range messages {
data := bytes.NewBuffer([]byte{})
reader := strings.NewReader(jsonMessage)
@@ -241,5 +248,6 @@ func TestDisplayJSONMessagesStream(t *testing.T) {
t.Fatalf("\nExpected %q\n got %q", expectedMessages[1], data.String())
}
}
os.Setenv("TERM", origTerm)
}