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
+ }
0 commit comments