-
Notifications
You must be signed in to change notification settings - Fork 0
/
reel.go
330 lines (294 loc) · 13 KB
/
reel.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
// Copyright (C) 2020-2021 Red Hat, Inc.
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
package reel
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
expect "github.com/google/goexpect"
"google.golang.org/grpc/codes"
)
const (
// EndOfTestSentinel is the emulated terminal prompt that will follow command output.
EndOfTestSentinel = `END_OF_TEST_SENTINEL`
// ExitKeyword keyword delimiting the command exit status
ExitKeyword = "exit="
)
var (
// matchSentinel This regular expression is matching stricly the sentinel and exit code.
// This match regular expression matches commands that return no output
matchSentinel = fmt.Sprintf("((.|\n)*%s %s[0-9]+\n)", EndOfTestSentinel, ExitKeyword)
// EndOfTestRegexPostfix This regular expression is a postfix added to the goexpect regular expressions. This regular expression matches a
// sentinel or marker string that is marking the end of the command output. This is because after the command
// output, the shell might also return a prompt which is not desired. Note: this is currently the same as the string above
// but was splitted for clarity
EndOfTestRegexPostfix = matchSentinel
)
// Step is an instruction for a single REEL pass.
// To process a step, first send the `Execute` string to the target subprocess (if supplied). Block until the
// subprocess output to stdout matches one of the regular expressions in `Expect` (if any supplied). A positive integer
// `Timeout` prevents blocking forever.
type Step struct {
// Execute is a Unix command to execute using the underlying subprocess.
Execute string `json:"execute,omitempty" yaml:"execute,omitempty"`
// Expect is an array of expected text regular expressions. The first expectation results in a match.
Expect []string `json:"expect,omitempty" yaml:"expect,omitempty"`
// Timeout is the timeout for the Step. A positive Timeout prevents blocking forever.
Timeout time.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"`
}
// A utility method to return the important aspects of the Step container as a tuple.
func (s *Step) unpack() (execute string, expect []string, timeout time.Duration) { //nolint:gocritic // Ignoring shadowed name `expect`; it makes sense
return s.Execute, s.Expect, s.Timeout
}
// Whether or not the Step has expectations.
func (s *Step) hasExpectations() bool {
return len(s.Expect) > 0
}
// A Handler implements desired programmatic control.
type Handler interface {
// ReelFirst returns the first step to perform.
ReelFirst() *Step
// ReelMatch informs of a match event, returning the next step to perform. ReelMatch takes three arguments:
// `pattern` represents the regular expression pattern which was matched.
// `before` contains all output preceding `match`.
// `match` is the text matched by `pattern`.
ReelMatch(pattern string, before string, match string) *Step
// ReelTimeout informs of a timeout event, returning the next step to perform.
ReelTimeout() *Step
// ReelEOF informs of the eof event.
ReelEOF()
}
// StepFunc provides a wrapper around a generic Handler.
type StepFunc func(Handler) *Step
// Option is a function pointer to enable lightweight optionals for Reel.
type Option func(reel *Reel) Option
// A Reel instance allows interaction with a target subprocess.
type Reel struct {
// A pointer to the underlying subprocess
expecter *expect.Expecter
Err error
// disableTerminalPromptEmulation determines whether terminal prompt emulation should be disabled.
disableTerminalPromptEmulation bool
}
// DisableTerminalPromptEmulation disables terminal prompt emulation for the reel.Reel.
// This disables terminal shell management and is used only for unit testing where the terminal is not available
// In this mode, go expect operates only on strings not command/shell outputs
func DisableTerminalPromptEmulation() Option {
return func(r *Reel) Option {
r.disableTerminalPromptEmulation = true
return DisableTerminalPromptEmulation()
}
}
// Each Step can have zero or more expectations (Step.Expect). This method follows the Adapter design pattern; a raw
// array of strings is turned into a corresponding array of expect.Batcher. This method side-effects the input
// expectations array, following the Builder design pattern. Finally, the first match is stored in the firstMatch
// output parameter.
// This command translates individual expectations in the test cases (e.g. success, failure, etc) into expect.Case in go expect
// The expect.Case are later matched in order inside goexpect ExpectBatch function.
func (r *Reel) batchExpectations(expectations []string, batcher []expect.Batcher, firstMatch *string) []expect.Batcher {
if len(expectations) > 0 {
expectCases := r.generateCases(expectations, firstMatch)
batcher = append(batcher, &expect.BCas{C: expectCases})
}
return batcher
}
// Each Step can have zero or more expectations (Step.Expect), and if any match is found, then a match event occurs
// (representing a logical "OR" over the array). This helper follows the Adapter design pattern; a raw array of string
// regular expressions is converted to an equivalent expect.Caser array. The firstMatch parameter is used as an output
// parameter to store the first match found in the expectations array. Thus, the order of expectations is important.
func (r *Reel) generateCases(expectations []string, firstMatch *string) []expect.Caser {
var cases []expect.Caser
// expectations created from test case matches
for _, expectation := range expectations {
thisCase := r.generateCase(expectation, firstMatch)
cases = append(cases, thisCase)
}
// extra test case to match when commands do not return anything but exit without error. This expectation makes
// sure that any command exiting successfully will be processed without timeout.
thisCase := r.generateCase("", firstMatch)
cases = append(cases, thisCase)
return cases
}
// Each Step can have zero or more expectations (Step.Expect). This method follows the Adapter design pattern; a
// single raw string Expectation is converted into a corresponding expect.Case.
func (r *Reel) generateCase(expectation string, firstMatch *string) *expect.Case {
expectation = r.addEmulatedRegularExpression(expectation)
return &expect.Case{R: regexp.MustCompile(expectation), T: func() (expect.Tag, *expect.Status) {
if *firstMatch == "" {
*firstMatch = expectation
}
return expect.OKTag, expect.NewStatus(codes.OK, "state reached")
}}
}
// Each Step can have exactly one execution string (Step.Execute). This method follows the Adapter design pattern; a
// single raw execution string is converted into a corresponding expect.Batcher. The function returns an array of
// expect.Batcher, as it is expected that there are likely expectations to follow.
func (r *Reel) generateBatcher(execute string) []expect.Batcher {
var batcher []expect.Batcher
if execute != "" {
execute = r.wrapTestCommand(execute)
batcher = append(batcher, &expect.BSnd{S: execute})
}
return batcher
}
// Determines if an error is an expect.TimeoutError.
func IsTimeout(err error) bool {
_, ok := err.(expect.TimeoutError)
return ok
}
// Step performs `step`, then, in response to events, consequent steps fed by `handler`.
// Return on first error, or when there is no next step to perform.
func (r *Reel) Step(step *Step, handler Handler) error {
for step != nil {
if r.Err != nil {
return r.Err
}
exec, exp, timeout := step.unpack()
var batchers []expect.Batcher
batchers = r.generateBatcher(exec)
// firstMatchRe is the first regular expression (expectation) that has matched results
var firstMatchRe string
batchers = r.batchExpectations(exp, batchers, &firstMatchRe)
results, err := (*r.expecter).ExpectBatch(batchers, timeout)
if !step.hasExpectations() {
return nil
}
if err != nil {
// record the err in reel in case the next step is nil as we return r.Err at the end
r.Err = err
if IsTimeout(err) {
step = handler.ReelTimeout()
} else {
step = nil
}
} else if len(results) > 0 {
result := results[0]
output, outputStatus := r.stripEmulatedPromptFromOutput(result.Output)
if outputStatus != 0 {
r.Err = fmt.Errorf("error executing command exit code:%d", outputStatus)
step = nil
continue
}
match, matchStatus := r.stripEmulatedPromptFromOutput(result.Match[0])
log.Debugf("command status: output=%s, match=%s, outputStatus=%d, matchStatus=%d, caseIndex=%d", output, match, outputStatus, matchStatus, result.CaseIdx)
// Check if the matching case is the extra one added in generateCases() for prompt return in error cases, skip calling ReelMatch if it is
if result.CaseIdx != len(batchers[result.Idx].Cases())-1 {
matchIndex := strings.Index(output, match)
var before string
// special case: the match regex may be nothing at all.
if matchIndex > 0 {
before = output[0 : matchIndex-1]
} else {
before = ""
}
strippedFirstMatchRe := r.stripEmulatedRegularExpression(firstMatchRe)
step = handler.ReelMatch(strippedFirstMatchRe, before, match)
} else {
step = nil
}
}
}
return r.Err
}
// Run the target subprocess to completion. The first step to take is supplied by handler. Consequent steps are
// determined by handler in response to events. Return on first error, or when there is no next step to execute.
func (r *Reel) Run(handler Handler) error {
return r.Step(handler.ReelFirst(), handler)
}
// Appends a new line to a command, if necessary.
func (r *Reel) createExecutableCommand(command string) string {
command = r.wrapTestCommand(command)
return command
}
// NewReel create a new `Reel` instance for interacting with a target subprocess. The command line for the target is
// specified by the args parameter.
func NewReel(expecter *expect.Expecter, args []string, errorChannel <-chan error, opts ...Option) (*Reel, error) {
r := &Reel{}
for _, o := range opts {
o(r)
}
if len(args) > 0 {
command := r.createExecutableCommand(strings.Join(args, " "))
err := (*expecter).Send(command)
if err != nil {
return nil, err
}
}
r.expecter = expecter
go func() {
r.Err = <-errorChannel
}()
return r, nil
}
// wrapTestCommand will wrap a test command in syntax to postfix a terminal emulation prompt.
func (r *Reel) wrapTestCommand(cmd string) string {
if !r.disableTerminalPromptEmulation {
return WrapTestCommand(cmd)
}
if !strings.HasSuffix(cmd, "\n") {
cmd += "\n"
}
return cmd
}
// WrapTestCommand wraps cmd so that the output will end in an emulated terminal prompt.
func WrapTestCommand(cmd string) string {
cmd = strings.TrimRight(cmd, "\n")
wrappedCommand := fmt.Sprintf("%s ; echo %s %s$?\n", cmd, EndOfTestSentinel, ExitKeyword)
log.Tracef("Command sent: %s", wrappedCommand)
return wrappedCommand
}
// stripEmulatedPromptFromOutput will elide the emulated terminal prompt from the test output.
func (r *Reel) stripEmulatedPromptFromOutput(output string) (data string, status int) {
parsed := strings.Split(output, EndOfTestSentinel)
var err error
if !r.disableTerminalPromptEmulation && len(parsed) == 2 {
// if a sentinel was present, then we have at least 2 parsed results
// if command retuned nothing parsed[0]==""
data = parsed[0]
status, err = strconv.Atoi(strings.Split(strings.Split(parsed[1], ExitKeyword)[1], "\n")[0])
if err != nil {
// Cannot parse status from output, something is wrong, fail command
status = 1
log.Errorf("Cannot determine command status. Error: %s", err)
}
// remove trailing \n if present
data = strings.TrimRight(data, "\n")
} else {
// to support unit tests (without sentinel parsing)
data = output
status = 0
log.Errorf("Cannot determine command status, no sentinel present. Error: %s", err)
}
return
}
// stripEmulatedRegularExpression will elide the modified part of the terminal prompt regular expression.
func (r *Reel) stripEmulatedRegularExpression(match string) string {
if !r.disableTerminalPromptEmulation && len(match) > len(EndOfTestRegexPostfix) {
return match[0 : len(match)-len(EndOfTestRegexPostfix)]
}
return match
}
// addEmulatedRegularExpression will append the additional regular expression to capture the emulated terminal prompt.
func (r *Reel) addEmulatedRegularExpression(regularExpressionString string) string {
if !r.disableTerminalPromptEmulation {
regularExpressionStringWithMetadata := fmt.Sprintf("%s%s", regularExpressionString, EndOfTestRegexPostfix)
return regularExpressionStringWithMetadata
}
return regularExpressionString
}