Skip to content

Commit

Permalink
Merge pull request #5720 from hashicorp/f-nomad-exec-escape
Browse files Browse the repository at this point in the history
Support escaping sequence for terminating alloc exec
  • Loading branch information
Mahmood Ali authored May 16, 2019
2 parents 3bcf7a0 + fb0d002 commit cbe88fa
Show file tree
Hide file tree
Showing 4 changed files with 516 additions and 6 deletions.
43 changes: 37 additions & 6 deletions command/alloc_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/docker/docker/pkg/term"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/contexts"
"github.com/hashicorp/nomad/helper/escapingio"
"github.com/posener/complete"
)

Expand Down Expand Up @@ -49,6 +50,12 @@ Exec Specific Options:
-t
Allocate a pseudo-tty, defaults to true if stdin is detected to be a tty session.
Pass -t=false to disable explicitly.
-e <escape_char>
Sets the escape character for sessions with a pty (default: '~'). The escape
character is only recognized at the beginning of a line. The escape character
followed by a dot ('.') closes the connection. Setting the character to
'none' disables any escapes and makes the session fully transparent.
`
return strings.TrimSpace(helpText)
}
Expand All @@ -64,6 +71,7 @@ func (c *AllocExecCommand) AutocompleteFlags() complete.Flags {
"-job": complete.PredictAnything,
"-i": complete.PredictNothing,
"-t": complete.PredictNothing,
"-e": complete.PredictSet("none", "~"),
})
}

Expand All @@ -87,11 +95,13 @@ func (l *AllocExecCommand) Name() string { return "alloc exec" }
func (l *AllocExecCommand) Run(args []string) int {
var job, stdinOpt, ttyOpt bool
var task string
var escapeChar string

flags := l.Meta.FlagSet(l.Name(), FlagSetClient)
flags.Usage = func() { l.Ui.Output(l.Help()) }
flags.BoolVar(&job, "job", false, "")
flags.StringVar(&task, "task", "", "")
flags.StringVar(&escapeChar, "e", "~", "")

flags.BoolVar(&stdinOpt, "i", true, "")

Expand All @@ -105,7 +115,14 @@ func (l *AllocExecCommand) Run(args []string) int {

if ttyOpt && !stdinOpt {
l.Ui.Error("-i must be enabled if running with tty")
return -1
return 1
}

if escapeChar == "none" {
escapeChar = ""
} else if len(escapeChar) > 1 {
l.Ui.Error("-e requires 'none' or a single character")
return 1
}

if numArgs := len(args); numArgs < 1 {
Expand Down Expand Up @@ -202,7 +219,7 @@ func (l *AllocExecCommand) Run(args []string) int {
stdin = bytes.NewReader(nil)
}

code, err := l.execImpl(client, alloc, task, ttyOpt, command, stdin, l.Stdout, l.Stderr)
code, err := l.execImpl(client, alloc, task, ttyOpt, command, escapeChar, stdin, l.Stdout, l.Stderr)
if err != nil {
l.Ui.Error(fmt.Sprintf("failed to exec into task: %v", err))
return 1
Expand All @@ -213,10 +230,13 @@ func (l *AllocExecCommand) Run(args []string) int {

// execImpl invokes the Alloc Exec api call, it also prepares and restores terminal states as necessary.
func (l *AllocExecCommand) execImpl(client *api.Client, alloc *api.Allocation, task string, tty bool,
command []string, stdin io.Reader, stdout, stderr io.WriteCloser) (int, error) {
command []string, escapeChar string, stdin io.Reader, stdout, stderr io.WriteCloser) (int, error) {

sizeCh := make(chan api.TerminalSize, 1)

ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()

// When tty, ensures we capture all user input and monitor terminal resizes.
if tty {
if stdin == nil {
Expand All @@ -240,10 +260,21 @@ func (l *AllocExecCommand) execImpl(client *api.Client, alloc *api.Allocation, t
return -1, err
}
defer sizeCleanup()
}

ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
if escapeChar != "" {
stdin = escapingio.NewReader(stdin, escapeChar[0], func(c byte) bool {
switch c {
case '.':
stderr.Write([]byte("\nConnection closed\n"))
cancelFn()
return true
default:
return false
}
})

}
}

signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
Expand Down
5 changes: 5 additions & 0 deletions command/alloc_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ func TestAllocExecCommand_Fails(t *testing.T) {
[]string{"-address=" + url, "26470238-5CF2-438F-8772-DC67CFB0705C"},
"A command is required",
},
{
"long escaped char",
[]string{"-address=" + url, "-e", "long_escape", "26470238-5CF2-438F-8772-DC67CFB0705C", "/bin/bash"},
"a single character",
},
}

for _, c := range cases {
Expand Down
163 changes: 163 additions & 0 deletions helper/escapingio/reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package escapingio

import (
"io"
)

// Handler is a callback for handling an escaped char. Reader would skip
// the escape char and passed char if returns true; otherwise, it preserves them
// in output
type Handler func(c byte) bool

// NewReader returns a reader that escapes the c character (following new lines),
// in the same manner OpenSSH handling, which defaults to `~`.
//
// For illustrative purposes, we use `~` in documentation as a shorthand for escaping character.
//
// If following a new line, reader sees:
// * `~~`, only one is emitted
// * `~.` (or any character), the handler is invoked with the character.
// If handler returns true, `~.` will be skipped; otherwise, it's propagated.
// * `~` and it's the last character in stream, it's propagated
//
// Appearances of `~` when not preceded by a new line are propagated unmodified.
func NewReader(r io.Reader, c byte, h Handler) io.Reader {
return &reader{
impl: r,
escapeChar: c,
state: sLookEscapeChar,
handler: h,
}
}

// lookState represents the state of reader for what character of `\n~.` sequence
// reader is looking for
type lookState int

const (
// sLookNewLine indicates that reader is looking for new line
sLookNewLine lookState = iota

// sLookEscapeChar indicates that reader is looking for ~
sLookEscapeChar

// sLookChar indicates that reader just read `~` is waiting for next character
// before acting
sLookChar
)

// to ease comments, i'll assume escape character to be `~`
type reader struct {
impl io.Reader
escapeChar uint8
handler Handler

state lookState

// unread is a buffered character for next read if not-nil
unread *byte
}

func (r *reader) Read(buf []byte) (int, error) {
START:
var n int
var err error

if r.unread != nil {
// try to return the unread character immediately
// without trying to block for another read
buf[0] = *r.unread
n = 1
r.unread = nil
} else {
n, err = r.impl.Read(buf)
}

// when we get to the end, check if we have any unprocessed \n~
if n == 0 && err != nil {
if r.state == sLookChar && err != nil {
buf[0] = r.escapeChar
n = 1
}
return n, err
}

// inspect the state at beginning of read
if r.state == sLookChar {
r.state = sLookNewLine

// escape character hasn't been emitted yet
if buf[0] == r.escapeChar {
// earlier ~ was swallowed already, so leave this as is
} else if handled := r.handler(buf[0]); handled {
// need to drop a single letter
copy(buf, buf[1:n])
n--
} else {
// we need to re-introduce ~ with rest of body
// but be mindful if reintroducing ~ causes buffer to overflow
if n == len(buf) {
// in which case, save it for next read
c := buf[n-1]
r.unread = &c
copy(buf[1:], buf[:n])
buf[0] = r.escapeChar
} else {
copy(buf[1:], buf[:n])
buf[0] = r.escapeChar
n++
}
}
}

n = r.processBuffer(buf, n)
if n == 0 && err == nil {
goto START
}

return n, err
}

// handles escaped character inside body of read buf.
func (r *reader) processBuffer(buf []byte, read int) int {
b := 0

for b < read {

c := buf[b]
if r.state == sLookEscapeChar && r.escapeChar == c {
r.state = sLookEscapeChar

// are we at the end of read; wait for next read
if b == read-1 {
read--
r.state = sLookChar
return read
}

// otherwise peek at next
nc := buf[b+1]
if nc == r.escapeChar {
// repeated ~, only emit one - skip one character
copy(buf[b:], buf[b+1:read])
read--
b++
continue
} else if handled := r.handler(nc); handled {
// need to drop both ~ and letter
copy(buf[b:], buf[b+2:read])
read -= 2
continue
} else {
// need to pass output unmodified with ~ and letter
}
} else if c == '\n' || c == '\r' {
r.state = sLookEscapeChar
} else {
r.state = sLookNewLine
}
b++
}

return read
}
Loading

0 comments on commit cbe88fa

Please sign in to comment.