|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | +"bufio" |
| 5 | +"bytes" |
| 6 | +"context" |
| 7 | +"encoding/json" |
| 8 | +"fmt" |
| 9 | +"io" |
| 10 | +"os" |
| 11 | +"strconv" |
| 12 | +"time" |
| 13 | + |
| 14 | +"github.com/spf13/cobra" |
| 15 | +"golang.org/x/xerrors" |
| 16 | + |
| 17 | +"github.com/coder/coder/cli/cliflag" |
| 18 | +"github.com/coder/coder/codersdk" |
| 19 | +"github.com/coder/coder/loadtest/harness" |
| 20 | +) |
| 21 | + |
| 22 | +funcloadtest()*cobra.Command { |
| 23 | +var ( |
| 24 | +configPathstring |
| 25 | +) |
| 26 | +cmd:=&cobra.Command{ |
| 27 | +Use:"loadtest --config <path>", |
| 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.", |
| 32 | +Hidden:true, |
| 33 | +Args:cobra.ExactArgs(0), |
| 34 | +RunE:func(cmd*cobra.Command,args []string)error { |
| 35 | +ifconfigPath=="" { |
| 36 | +returnxerrors.New("config is required") |
| 37 | +} |
| 38 | + |
| 39 | +var ( |
| 40 | +configReader io.ReadCloser |
| 41 | +) |
| 42 | +ifconfigPath=="-" { |
| 43 | +configReader=io.NopCloser(cmd.InOrStdin()) |
| 44 | +}else { |
| 45 | +f,err:=os.Open(configPath) |
| 46 | +iferr!=nil { |
| 47 | +returnxerrors.Errorf("open config file %q: %w",configPath,err) |
| 48 | +} |
| 49 | +configReader=f |
| 50 | +} |
| 51 | + |
| 52 | +varconfigLoadTestConfig |
| 53 | +err:=json.NewDecoder(configReader).Decode(&config) |
| 54 | +_=configReader.Close() |
| 55 | +iferr!=nil { |
| 56 | +returnxerrors.Errorf("read config file %q: %w",configPath,err) |
| 57 | +} |
| 58 | + |
| 59 | +err=config.Validate() |
| 60 | +iferr!=nil { |
| 61 | +returnxerrors.Errorf("validate config: %w",err) |
| 62 | +} |
| 63 | + |
| 64 | +client,err:=CreateClient(cmd) |
| 65 | +iferr!=nil { |
| 66 | +returnerr |
| 67 | +} |
| 68 | + |
| 69 | +me,err:=client.User(cmd.Context(),codersdk.Me) |
| 70 | +iferr!=nil { |
| 71 | +returnxerrors.Errorf("fetch current user: %w",err) |
| 72 | +} |
| 73 | + |
| 74 | +// Only owners can do loadtests. This isn't a very strong check but |
| 75 | +// there's not much else we can do. Ratelimits are enforced for |
| 76 | +// non-owners so hopefully that limits the damage if someone |
| 77 | +// disables this check and runs it against a non-owner account. |
| 78 | +ok:=false |
| 79 | +for_,role:=rangeme.Roles { |
| 80 | +ifrole.Name=="owner" { |
| 81 | +ok=true |
| 82 | +break |
| 83 | +} |
| 84 | +} |
| 85 | +if!ok { |
| 86 | +returnxerrors.Errorf("Not logged in as site owner. Load testing is only available to site owners.") |
| 87 | +} |
| 88 | + |
| 89 | +// Disable ratelimits for future requests. |
| 90 | +client.BypassRatelimits=true |
| 91 | + |
| 92 | +// Prepare the test. |
| 93 | +strategy:=config.Strategy.ExecutionStrategy() |
| 94 | +th:=harness.NewTestHarness(strategy) |
| 95 | + |
| 96 | +fori,t:=rangeconfig.Tests { |
| 97 | +name:=fmt.Sprintf("%s-%d",t.Type,i) |
| 98 | + |
| 99 | +forj:=0;j<t.Count;j++ { |
| 100 | +id:=strconv.Itoa(j) |
| 101 | +runner,err:=t.NewRunner(client) |
| 102 | +iferr!=nil { |
| 103 | +returnxerrors.Errorf("create %q runner for %s/%s: %w",t.Type,name,id,err) |
| 104 | +} |
| 105 | + |
| 106 | +th.AddRun(name,id,runner) |
| 107 | +} |
| 108 | +} |
| 109 | + |
| 110 | +_,_=fmt.Fprintln(cmd.ErrOrStderr(),"Running load test...") |
| 111 | + |
| 112 | +testCtx:=cmd.Context() |
| 113 | +ifconfig.Timeout>0 { |
| 114 | +varcancelfunc() |
| 115 | +testCtx,cancel=context.WithTimeout(testCtx,time.Duration(config.Timeout)) |
| 116 | +defercancel() |
| 117 | +} |
| 118 | + |
| 119 | +// TODO: live progress output |
| 120 | +start:=time.Now() |
| 121 | +err=th.Run(testCtx) |
| 122 | +iferr!=nil { |
| 123 | +returnxerrors.Errorf("run test harness (harness failure, not a test failure): %w",err) |
| 124 | +} |
| 125 | +elapsed:=time.Since(start) |
| 126 | + |
| 127 | +// 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 | +res:=th.Results() |
| 132 | +vartotalDuration time.Duration |
| 133 | +for_,run:=rangeres.Runs { |
| 134 | +totalDuration+=run.Duration |
| 135 | +ifrun.Error==nil { |
| 136 | +continue |
| 137 | +} |
| 138 | + |
| 139 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\n== FAIL: %s\n\n",run.FullID) |
| 140 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tError: %s\n\n",run.Error) |
| 141 | + |
| 142 | +// Print log lines indented. |
| 143 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tLog:\n") |
| 144 | +rd:=bufio.NewReader(bytes.NewBuffer(run.Logs)) |
| 145 | +for { |
| 146 | +line,err:=rd.ReadBytes('\n') |
| 147 | +iferr==io.EOF { |
| 148 | +break |
| 149 | +} |
| 150 | +iferr!=nil { |
| 151 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\n\tLOG PRINT ERROR: %+v\n",err) |
| 152 | +} |
| 153 | + |
| 154 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\t\t%s",line) |
| 155 | +} |
| 156 | +} |
| 157 | + |
| 158 | +_,_=fmt.Fprintln(cmd.ErrOrStderr(),"\n\nTest results:") |
| 159 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tPass: %d\n",res.TotalPass) |
| 160 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tFail: %d\n",res.TotalFail) |
| 161 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tTotal: %d\n",res.TotalRuns) |
| 162 | +_,_=fmt.Fprintln(cmd.ErrOrStderr(),"") |
| 163 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tTotal duration: %s\n",elapsed) |
| 164 | +_,_=fmt.Fprintf(cmd.ErrOrStderr(),"\tAvg. duration: %s\n",totalDuration/time.Duration(res.TotalRuns)) |
| 165 | + |
| 166 | +// Cleanup. |
| 167 | +_,_=fmt.Fprintln(cmd.ErrOrStderr(),"\nCleaning up...") |
| 168 | +err=th.Cleanup(cmd.Context()) |
| 169 | +iferr!=nil { |
| 170 | +returnxerrors.Errorf("cleanup tests: %w",err) |
| 171 | +} |
| 172 | + |
| 173 | +returnnil |
| 174 | +}, |
| 175 | +} |
| 176 | + |
| 177 | +cliflag.StringVarP(cmd.Flags(),&configPath,"config","","CODER_LOADTEST_CONFIG_PATH","","Path to the load test configuration file, or - to read from stdin.") |
| 178 | +returncmd |
| 179 | +} |