Skip to content

Commit c44a2b2

Browse files
fix: add custom step output scanner with error detection (#216)
* feat: add custom step output scanner with error detection * fix lint * improve keywords * add comment * fix exec error handling bug and update error detection * fix err handling * fix lint * simplify logic * require approval for destroy stages too --------- Co-authored-by: michaeljguarino <[email protected]>
1 parent e60d5fc commit c44a2b2

File tree

10 files changed

+233
-34
lines changed

10 files changed

+233
-34
lines changed

go.mod

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ require (
1919
github.com/open-policy-agent/gatekeeper/v3 v3.15.1
2020
github.com/orcaman/concurrent-map/v2 v2.0.1
2121
github.com/pkg/errors v0.9.1
22-
github.com/pluralsh/console-client-go v0.5.13
22+
github.com/pluralsh/console-client-go v0.5.18
2323
github.com/pluralsh/controller-reconcile-helper v0.0.4
2424
github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34
2525
github.com/pluralsh/polly v0.1.10
@@ -39,7 +39,6 @@ require (
3939
k8s.io/apimachinery v0.29.3
4040
k8s.io/cli-runtime v0.29.2
4141
k8s.io/client-go v0.29.2
42-
k8s.io/klog v1.0.0
4342
k8s.io/klog/v2 v2.110.1
4443
k8s.io/kubectl v0.29.2
4544
layeh.com/gopher-luar v1.0.11

go.sum

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,6 @@ github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3I
207207
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
208208
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
209209
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
210-
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
211210
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
212211
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
213212
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
@@ -527,8 +526,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
527526
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
528527
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
529528
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
530-
github.com/pluralsh/console-client-go v0.5.13 h1:HOmkho5aaU42f6PkSb+BUFjhCJKnL5jceLZiT16HMBE=
531-
github.com/pluralsh/console-client-go v0.5.13/go.mod h1:eyCiLA44YbXiYyJh8303jk5JdPkt9McgCo5kBjk4lKo=
529+
github.com/pluralsh/console-client-go v0.5.18 h1:uwYsoGaggvi3uPZYL/+qdhvgl73sGBiuVUfQGAC/J4c=
530+
github.com/pluralsh/console-client-go v0.5.18/go.mod h1:eyCiLA44YbXiYyJh8303jk5JdPkt9McgCo5kBjk4lKo=
532531
github.com/pluralsh/controller-reconcile-helper v0.0.4 h1:1o+7qYSyoeqKFjx+WgQTxDz4Q2VMpzprJIIKShxqG0E=
533532
github.com/pluralsh/controller-reconcile-helper v0.0.4/go.mod h1:AfY0gtteD6veBjmB6jiRx/aR4yevEf6K0M13/pGan/s=
534533
github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34 h1:ab2PN+6if/Aq3/sJM0AVdy1SYuMAnq4g20VaKhTm/Bw=
@@ -1095,8 +1094,6 @@ k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg=
10951094
k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA=
10961095
k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8=
10971096
k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM=
1098-
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
1099-
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
11001097
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
11011098
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
11021099
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=

pkg/harness/controller/controller.go

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ func (in *stackRunController) toExecutable(ctx context.Context, step *gqlclient.
100100
// ensuring they have completed all work.
101101
in.wg.Add(1)
102102

103+
toolWriters := make([]io.WriteCloser, 0)
104+
modifier := in.tool.Modifier(step.Stage)
105+
args := step.Args
106+
env := in.stackRun.Env()
103107
consoleWriter := sink.NewConsoleWriter(
104108
ctx,
105109
in.consoleClient,
@@ -115,29 +119,35 @@ func (in *stackRunController) toExecutable(ctx context.Context, step *gqlclient.
115119
)...,
116120
)
117121

118-
var toolWriters []io.WriteCloser
119-
modifier := in.tool.Modifier(step.Stage)
120-
args := step.Args
121-
env := in.stackRun.Env()
122122
if modifier != nil {
123123
args = modifier.Args(args)
124124
env = modifier.Env(env)
125125
toolWriters = modifier.WriteCloser()
126126
}
127127

128-
return exec.NewExecutable(
129-
step.Cmd,
130-
append(
131-
in.execOptions,
132-
exec.WithDir(in.execWorkDir()),
133-
exec.WithEnv(env),
134-
exec.WithArgs(args),
135-
exec.WithID(step.ID),
136-
exec.WithOutputSinks(append(toolWriters, consoleWriter)...),
137-
exec.WithHook(v1.LifecyclePreStart, in.preExecHook(step.Stage, step.ID)),
138-
exec.WithHook(v1.LifecyclePostStart, in.postExecHook(step.Stage)),
139-
)...,
128+
// base executable options
129+
options := in.execOptions
130+
options = append(
131+
options,
132+
exec.WithDir(in.execWorkDir()),
133+
exec.WithEnv(env),
134+
exec.WithArgs(args),
135+
exec.WithID(step.ID),
136+
exec.WithOutputSinks(append(toolWriters, consoleWriter)...),
137+
exec.WithHook(v1.LifecyclePreStart, in.preExecHook(step.Stage, step.ID)),
138+
exec.WithHook(v1.LifecyclePostStart, in.postExecHook(step.Stage)),
139+
exec.WithOutputAnalyzer(exec.NewKeywordDetector()),
140140
)
141+
142+
// Add a custom run step output analyzer for the destroy stage to increase
143+
// a chance of detecting errors during execution. On occasion executable can
144+
// return exit code 0 even though there was a fatal error during execution.
145+
// TODO: use destroy stage
146+
// if step.Stage == gqlclient.StepStageApply {
147+
// options = append(options, exec.WithOutputAnalyzer(exec.NewKeywordDetector()))
148+
//}
149+
150+
return exec.NewExecutable(step.Cmd, options...)
141151
}
142152

143153
func (in *stackRunController) completeStackRun(status gqlclient.StackStatus, stackRunErr error) error {

pkg/harness/controller/controller_hooks.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (in *stackRunController) postExecHook(stage gqlclient.StepStage) v1.HookFun
9191
// postExecHook is a callback function started by the exec.Executable before it runs the executable.
9292
func (in *stackRunController) preExecHook(stage gqlclient.StepStage, id string) v1.HookFunction {
9393
return func() error {
94-
if stage == gqlclient.StepStageApply && in.requiresApproval() {
94+
if (stage == gqlclient.StepStageApply || stage == gqlclient.StepStageDestroy) && in.requiresApproval() {
9595
in.waitForApproval()
9696
}
9797

pkg/harness/exec/analyzer.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package exec
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"fmt"
7+
"io"
8+
"strings"
9+
)
10+
11+
type outputAnalyzer struct {
12+
stdout *bytes.Buffer
13+
stderr *bytes.Buffer
14+
15+
heuristics []OutputAnalyzerHeuristic
16+
}
17+
18+
func (in *outputAnalyzer) Stdout() io.Writer {
19+
return in.stdout
20+
}
21+
22+
func (in *outputAnalyzer) Stderr() io.Writer {
23+
return in.stderr
24+
}
25+
26+
func (in *outputAnalyzer) Detect() []error {
27+
errors := make([]error, 0)
28+
output := in.stdout.String()
29+
30+
for _, heuristic := range in.heuristics {
31+
if potentialErrors := heuristic.Detect(bufio.NewScanner(strings.NewReader(output))); len(potentialErrors) > 0 {
32+
errors = append(errors, potentialErrors.ToErrors()...)
33+
}
34+
}
35+
36+
if in.stderr.Len() > 0 {
37+
errors = append(errors, fmt.Errorf("%s", in.stderr.String()))
38+
}
39+
40+
return errors
41+
}
42+
43+
func NewOutputAnalyzer(heuristics ...OutputAnalyzerHeuristic) OutputAnalyzer {
44+
return &outputAnalyzer{
45+
stdout: bytes.NewBuffer([]byte{}),
46+
stderr: bytes.NewBuffer([]byte{}),
47+
heuristics: heuristics,
48+
}
49+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package exec
2+
3+
import (
4+
"bufio"
5+
"strings"
6+
7+
"github.com/pluralsh/polly/algorithms"
8+
)
9+
10+
type keywordDetector struct {
11+
keywords []keyword
12+
}
13+
14+
type keyword struct {
15+
content string
16+
ignoreCase bool
17+
}
18+
19+
func (in keyword) PartOf(s string) bool {
20+
if !in.ignoreCase {
21+
return strings.Contains(s, in.content)
22+
}
23+
24+
return strings.Contains(
25+
strings.ToLower(s),
26+
strings.ToLower(in.content),
27+
)
28+
}
29+
30+
// Detect implements [OutputAnalyzerHeuristic] interface.
31+
// TODO: we can spread actual message analysis into multiple routines to speed up the process.
32+
func (in *keywordDetector) Detect(input *bufio.Scanner) Errors {
33+
line := 0
34+
errors := make([]Error, 0)
35+
for input.Scan() {
36+
if !in.hasError(input.Text()) {
37+
continue
38+
}
39+
40+
errors = append(errors, Error{
41+
line: line,
42+
message: input.Text(),
43+
})
44+
}
45+
46+
return errors
47+
}
48+
49+
func (in *keywordDetector) hasError(message string) bool {
50+
return algorithms.Index(in.keywords, func(k keyword) bool {
51+
return k.PartOf(message)
52+
}) >= 0
53+
}
54+
55+
func NewKeywordDetector() OutputAnalyzerHeuristic {
56+
return &keywordDetector{
57+
keywords: []keyword{
58+
{"error message: http remote state already locked", true},
59+
{"error acquiring the state lock", true},
60+
{"Error:", false},
61+
},
62+
}
63+
}

pkg/harness/exec/analyzer_types.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package exec
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
)
8+
9+
// OutputAnalyzer captures the command output
10+
// and attempts to detect potential errors.
11+
type OutputAnalyzer interface {
12+
Stdout() io.Writer
13+
Stderr() io.Writer
14+
15+
// Detect scans the output for potential errors.
16+
// It uses a custom heuristics to detect issues.
17+
// It can result in a false positives.
18+
//
19+
// Note: Make sure that it is executed after Write
20+
// has finished to ensure proper detection.
21+
Detect() []error
22+
}
23+
24+
type OutputAnalyzerHeuristic interface {
25+
Detect(input *bufio.Scanner) Errors
26+
}
27+
28+
type Error struct {
29+
line int
30+
message string
31+
}
32+
33+
func (in Error) ToError() error {
34+
return fmt.Errorf("[%d] %s", in.line, in.message)
35+
}
36+
37+
type Errors []Error
38+
39+
func (in Errors) ToErrors() []error {
40+
errors := make([]error, len(in))
41+
for _, err := range in {
42+
errors = append(errors, err.ToError())
43+
}
44+
45+
return errors
46+
}

pkg/harness/exec/exec.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package exec
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"io"
78
"os"
@@ -35,6 +36,10 @@ func (in *executable) Run(ctx context.Context) error {
3536
cmd.Stdout = w
3637
cmd.Stderr = w
3738

39+
if in.outputAnalyzer != nil {
40+
cmd.Stderr = io.MultiWriter(w, in.outputAnalyzer.Stderr())
41+
}
42+
3843
// Configure environment of the executable.
3944
// Root process environment is used as a base and passed in env vars
4045
// are added on top of that. In case of duplicate keys, custom env
@@ -47,13 +52,17 @@ func (in *executable) Run(ctx context.Context) error {
4752

4853
klog.V(log.LogLevelExtended).InfoS("executing", "command", in.Command())
4954
if err := cmd.Run(); err != nil {
50-
if err = context.Cause(ctx); err != nil {
55+
if err := context.Cause(ctx); err != nil {
5156
return err
5257
}
5358

5459
return err
5560
}
5661

62+
if err := in.analyze(); err != nil {
63+
return err
64+
}
65+
5766
return in.runLifecycleFunction(v1.LifecyclePostStart)
5867
}
5968

@@ -87,17 +96,22 @@ func (in *executable) ID() string {
8796
}
8897

8998
func (in *executable) writer() io.Writer {
90-
if len(in.outputSinks) == 0 {
91-
return os.Stdout
92-
}
99+
writers := []io.Writer{os.Stdout}
93100

94-
return io.MultiWriter(
95-
append(
96-
algorithms.Map(in.outputSinks, func(writer io.WriteCloser) io.Writer {
101+
if len(in.outputSinks) > 0 {
102+
writers = append(writers, algorithms.Map(
103+
in.outputSinks,
104+
func(writer io.WriteCloser) io.Writer {
97105
return writer
98-
}),
99-
os.Stdout)...,
100-
)
106+
})...,
107+
)
108+
}
109+
110+
if in.outputAnalyzer != nil {
111+
writers = append(writers, in.outputAnalyzer.Stdout())
112+
}
113+
114+
return io.MultiWriter(writers...)
101115
}
102116

103117
func (in *executable) close(writers []io.WriteCloser) {
@@ -120,6 +134,18 @@ func (in *executable) runLifecycleFunction(lifecycle v1.Lifecycle) error {
120134
return nil
121135
}
122136

137+
func (in *executable) analyze() error {
138+
if in.outputAnalyzer == nil {
139+
return nil
140+
}
141+
142+
if err := in.outputAnalyzer.Detect(); len(err) > 0 {
143+
return errors.Join(err...)
144+
}
145+
146+
return nil
147+
}
148+
123149
func NewExecutable(command string, options ...Option) Executable {
124150
result := &executable{
125151
command: command,

pkg/harness/exec/exec_options.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,9 @@ func WithTimeout(timeout time.Duration) Option {
5050
e.timeout = timeout
5151
}
5252
}
53+
54+
func WithOutputAnalyzer(heuristics ...OutputAnalyzerHeuristic) Option {
55+
return func(e *executable) {
56+
e.outputAnalyzer = NewOutputAnalyzer(heuristics...)
57+
}
58+
}

pkg/harness/exec/exec_types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ type executable struct {
4444
// to the [os.Stdout].
4545
outputSinks []io.WriteCloser
4646

47+
// outputAnalyzer
48+
outputAnalyzer OutputAnalyzer
49+
4750
// hookFunctions ...
4851
hookFunctions map[v1.Lifecycle]v1.HookFunction
4952
}

0 commit comments

Comments
 (0)