1
1
package cli
2
2
3
3
import (
4
- "bufio"
5
- "bytes"
6
4
"context"
7
5
"encoding/json"
8
6
"fmt"
9
7
"io"
10
8
"os"
11
9
"strconv"
10
+ "strings"
12
11
"time"
13
12
14
13
"github.com/spf13/cobra"
@@ -21,44 +20,46 @@ import (
21
20
22
21
func loadtest ()* cobra.Command {
23
22
var (
24
- configPath string
23
+ configPath string
24
+ outputSpecs []string
25
25
)
26
26
cmd := & cobra.Command {
27
- Use :"loadtest --config <path>" ,
27
+ Use :"loadtest --config <path> [--output json[:path]] [--output text[:path]]] " ,
28
28
Short :"Load test the Coder API" ,
29
- // TODO: documentation and a JSON scheme file
30
- Long :"Perform load tests against the Coder server. The load tests " +
31
- "configurable via a JSON file." ,
29
+ // TODO: documentation and a JSON schema file
30
+ Long :"Perform load tests against the Coder server. The load tests are configurable via a JSON file." ,
31
+ Example :formatExamples (
32
+ example {
33
+ Description :"Run a loadtest with the given configuration file" ,
34
+ Command :"coder loadtest --config path/to/config.json" ,
35
+ },
36
+ example {
37
+ Description :"Run a loadtest, reading the configuration from stdin" ,
38
+ Command :"cat path/to/config.json | coder loadtest --config -" ,
39
+ },
40
+ example {
41
+ Description :"Run a loadtest outputting JSON results instead" ,
42
+ Command :"coder loadtest --config path/to/config.json --output json" ,
43
+ },
44
+ example {
45
+ Description :"Run a loadtest outputting JSON results to a file" ,
46
+ Command :"coder loadtest --config path/to/config.json --output json:path/to/results.json" ,
47
+ },
48
+ example {
49
+ Description :"Run a loadtest outputting text results to stdout and JSON results to a file" ,
50
+ Command :"coder loadtest --config path/to/config.json --output text --output json:path/to/results.json" ,
51
+ },
52
+ ),
32
53
Hidden :true ,
33
54
Args :cobra .ExactArgs (0 ),
34
55
RunE :func (cmd * cobra.Command ,args []string )error {
35
- if configPath == "" {
36
- return xerrors .New ("config is required" )
37
- }
38
-
39
- var (
40
- configReader io.ReadCloser
41
- )
42
- if configPath == "-" {
43
- configReader = io .NopCloser (cmd .InOrStdin ())
44
- }else {
45
- f ,err := os .Open (configPath )
46
- if err != nil {
47
- return xerrors .Errorf ("open config file %q: %w" ,configPath ,err )
48
- }
49
- configReader = f
50
- }
51
-
52
- var config LoadTestConfig
53
- err := json .NewDecoder (configReader ).Decode (& config )
54
- _ = configReader .Close ()
56
+ config ,err := loadLoadTestConfigFile (configPath ,cmd .InOrStdin ())
55
57
if err != nil {
56
- return xerrors . Errorf ( "read config file %q: %w" , configPath , err )
58
+ return err
57
59
}
58
-
59
- err = config .Validate ()
60
+ outputs ,err := parseLoadTestOutputs (outputSpecs )
60
61
if err != nil {
61
- return xerrors . Errorf ( "validate config: %w" , err )
62
+ return err
62
63
}
63
64
64
65
client ,err := CreateClient (cmd )
@@ -117,63 +118,156 @@ func loadtest() *cobra.Command {
117
118
}
118
119
119
120
// TODO: live progress output
120
- start := time .Now ()
121
121
err = th .Run (testCtx )
122
122
if err != nil {
123
123
return xerrors .Errorf ("run test harness (harness failure, not a test failure): %w" ,err )
124
124
}
125
- elapsed := time .Since (start )
126
125
127
126
// Print the results.
128
- // TODO: better result printing
129
- // TODO: move result printing to the loadtest package, add multiple
130
- // output formats (like HTML, JSON)
131
127
res := th .Results ()
132
- var totalDuration time.Duration
133
- for _ ,run := range res .Runs {
134
- totalDuration += run .Duration
135
- if run .Error == nil {
136
- continue
128
+ for _ ,output := range outputs {
129
+ var (
130
+ w = cmd .OutOrStdout ()
131
+ c io.Closer
132
+ )
133
+ if output .path != "-" {
134
+ f ,err := os .Create (output .path )
135
+ if err != nil {
136
+ return xerrors .Errorf ("create output file: %w" ,err )
137
+ }
138
+ w ,c = f ,f
137
139
}
138
140
139
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\n == FAIL: %s\n \n " ,run .FullID )
140
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Error: %s\n \n " ,run .Error )
141
-
142
- // Print log lines indented.
143
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Log:\n " )
144
- rd := bufio .NewReader (bytes .NewBuffer (run .Logs ))
145
- for {
146
- line ,err := rd .ReadBytes ('\n' )
147
- if err == io .EOF {
148
- break
149
- }
141
+ switch output .format {
142
+ case loadTestOutputFormatText :
143
+ res .PrintText (w )
144
+ case loadTestOutputFormatJSON :
145
+ err = json .NewEncoder (w ).Encode (res )
150
146
if err != nil {
151
- _ , _ = fmt . Fprintf ( cmd . ErrOrStderr (), " \n \t LOG PRINT ERROR : %+v \n " ,err )
147
+ return xerrors . Errorf ( "encode JSON : %w " ,err )
152
148
}
149
+ }
153
150
154
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t \t %s" ,line )
151
+ if c != nil {
152
+ err = c .Close ()
153
+ if err != nil {
154
+ return xerrors .Errorf ("close output file: %w" ,err )
155
+ }
155
156
}
156
157
}
157
158
158
- _ ,_ = fmt .Fprintln (cmd .ErrOrStderr (),"\n \n Test results:" )
159
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Pass: %d\n " ,res .TotalPass )
160
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Fail: %d\n " ,res .TotalFail )
161
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Total: %d\n " ,res .TotalRuns )
162
- _ ,_ = fmt .Fprintln (cmd .ErrOrStderr (),"" )
163
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Total duration: %s\n " ,elapsed )
164
- _ ,_ = fmt .Fprintf (cmd .ErrOrStderr (),"\t Avg. duration: %s\n " ,totalDuration / time .Duration (res .TotalRuns ))
165
-
166
159
// Cleanup.
167
160
_ ,_ = fmt .Fprintln (cmd .ErrOrStderr (),"\n Cleaning up..." )
168
161
err = th .Cleanup (cmd .Context ())
169
162
if err != nil {
170
163
return xerrors .Errorf ("cleanup tests: %w" ,err )
171
164
}
172
165
166
+ if res .TotalFail > 0 {
167
+ return xerrors .New ("load test failed, see above for more details" )
168
+ }
169
+
173
170
return nil
174
171
},
175
172
}
176
173
177
174
cliflag .StringVarP (cmd .Flags (),& configPath ,"config" ,"" ,"CODER_LOADTEST_CONFIG_PATH" ,"" ,"Path to the load test configuration file, or - to read from stdin." )
175
+ cliflag .StringArrayVarP (cmd .Flags (),& outputSpecs ,"output" ,"" ,"CODER_LOADTEST_OUTPUTS" , []string {"text" },"Output formats, see usage for more information." )
178
176
return cmd
179
177
}
178
+
179
+ func loadLoadTestConfigFile (configPath string ,stdin io.Reader ) (LoadTestConfig ,error ) {
180
+ if configPath == "" {
181
+ return LoadTestConfig {},xerrors .New ("config is required" )
182
+ }
183
+
184
+ var (
185
+ configReader io.ReadCloser
186
+ )
187
+ if configPath == "-" {
188
+ configReader = io .NopCloser (stdin )
189
+ }else {
190
+ f ,err := os .Open (configPath )
191
+ if err != nil {
192
+ return LoadTestConfig {},xerrors .Errorf ("open config file %q: %w" ,configPath ,err )
193
+ }
194
+ configReader = f
195
+ }
196
+
197
+ var config LoadTestConfig
198
+ err := json .NewDecoder (configReader ).Decode (& config )
199
+ _ = configReader .Close ()
200
+ if err != nil {
201
+ return LoadTestConfig {},xerrors .Errorf ("read config file %q: %w" ,configPath ,err )
202
+ }
203
+
204
+ err = config .Validate ()
205
+ if err != nil {
206
+ return LoadTestConfig {},xerrors .Errorf ("validate config: %w" ,err )
207
+ }
208
+
209
+ return config ,nil
210
+ }
211
+
212
+ type loadTestOutputFormat string
213
+
214
+ const (
215
+ loadTestOutputFormatText loadTestOutputFormat = "text"
216
+ loadTestOutputFormatJSON loadTestOutputFormat = "json"
217
+ // TODO: html format
218
+ )
219
+
220
+ type loadTestOutput struct {
221
+ format loadTestOutputFormat
222
+ // Up to one path (the first path) will have the value "-" which signifies
223
+ // stdout.
224
+ path string
225
+ }
226
+
227
+ func parseLoadTestOutputs (outputs []string ) ([]loadTestOutput ,error ) {
228
+ var stdoutFormat loadTestOutputFormat
229
+
230
+ validFormats := map [loadTestOutputFormat ]struct {}{
231
+ loadTestOutputFormatText : {},
232
+ loadTestOutputFormatJSON : {},
233
+ }
234
+
235
+ var out []loadTestOutput
236
+ for i ,o := range outputs {
237
+ parts := strings .SplitN (o ,":" ,2 )
238
+ format := loadTestOutputFormat (parts [0 ])
239
+ if _ ,ok := validFormats [format ];! ok {
240
+ return nil ,xerrors .Errorf ("invalid output format %q in output flag %d" ,parts [0 ],i )
241
+ }
242
+
243
+ if len (parts )== 1 {
244
+ if stdoutFormat != "" {
245
+ return nil ,xerrors .Errorf ("multiple output flags specified for stdout" )
246
+ }
247
+ stdoutFormat = format
248
+ continue
249
+ }
250
+ if len (parts )!= 2 {
251
+ return nil ,xerrors .Errorf ("invalid output flag %d: %q" ,i ,o )
252
+ }
253
+
254
+ out = append (out ,loadTestOutput {
255
+ format :format ,
256
+ path :parts [1 ],
257
+ })
258
+ }
259
+
260
+ // Default to --output text
261
+ if stdoutFormat == "" && len (out )== 0 {
262
+ stdoutFormat = loadTestOutputFormatText
263
+ }
264
+
265
+ if stdoutFormat != "" {
266
+ out = append ([]loadTestOutput {{
267
+ format :stdoutFormat ,
268
+ path :"-" ,
269
+ }},out ... )
270
+ }
271
+
272
+ return out ,nil
273
+ }