@@ -22,33 +22,36 @@ import (
2222 "github.com/maansaake/arbiter/pkg/subcommand/gen"
2323 "github.com/maansaake/arbiter/pkg/traffic"
2424 "github.com/spf13/cobra"
25+ "github.com/spf13/pflag"
2526 "github.com/trebent/zerologr"
2627)
2728
2829const (
29- durationDefault = time .Minute * 5
30- reportPathDefault = "report.yaml"
30+ defaultDuration = time .Minute * 5
31+ defaultReportPath = "report.yaml"
32+ defaultInteractive = false
3133)
3234
3335var (
3436 // global flag vars.
3537 //nolint:gochecknoglobals // modified by flag parsing
36- duration = durationDefault
37-
38- // logger.
39- //nolint:gochecknoglobals // glob log
40- startLogger = zerologr .New (& zerologr.Opts {Console : true }).WithName ("start" )
38+ duration time.Duration
4139
4240 // report.
43- reportPath = reportPathDefault //nolint:gochecknoglobals // modified by flag parsing
41+ reportPath string //nolint:gochecknoglobals // modified by flag parsing
4442
4543 // interactive enables the live TUI dashboard.
4644 //nolint:gochecknoglobals // modified by flag parsing
47- interactive = false
45+ interactive bool
4846
4947 // rootCmd holds the cobra root command for Usage access.
5048 //nolint:gochecknoglobals // package-level command for Usage access
5149 rootCmd * cobra.Command
50+
51+ // ErrStopping is returned when there was an error stopping traffic or modules.
52+ // It is used to wrap any errors from traffic or module stopping to allow
53+ // callers to check for this specific case.
54+ ErrStopping = errors .New ("error stopping traffic or modules" )
5255)
5356
5457//nolint:gochecknoinits // sets up global logger at package load
@@ -65,79 +68,38 @@ func Usage() {
6568// Run the Arbiter. Blocks until SIGINT, SIGTERM or when the test duration
6669// runs out (5 minute default).
6770func Run (modules module.Modules ) error {
68- // TODO: change to support > 1 module
69- if len (modules ) != 1 {
70- return fmt .Errorf ("currently only 1 module is supported, got %d" , len (modules ))
71- }
72-
7371 if err := module .Validate (modules ); err != nil {
7472 return err
7573 }
7674
7775 // Reset to defaults on each Run call.
78- duration = durationDefault
79- reportPath = reportPathDefault
80- interactive = false
76+ duration = defaultDuration
77+ reportPath = defaultReportPath
78+ interactive = defaultInteractive
8179
80+ // Root cmd that all subcommands are added to.
8281 rootCmd = & cobra.Command {
8382 Use : "arbiter" ,
84- Short : "Arbiter load testing framework ." ,
83+ Short : "Arbiter load testing." ,
8584 SilenceErrors : true ,
8685 SilenceUsage : true ,
8786 }
8887
89- rootCmd .PersistentFlags ().
90- DurationVarP (& duration , "duration" , "d" , durationDefault , "The duration of the test run, minimum 1 second." )
91- rootCmd .PersistentFlags ().
92- StringVarP (& reportPath , "report-path" , "r" , reportPathDefault , "Path to the final report." )
93- rootCmd .PersistentFlags ().
94- BoolVarP (& interactive , "interactive" , "i" , false , "Start in interactive TUI mode with a live progress bar and per-operation statistics." )
95-
96- cliCmd , err := cli .NewCommand (modules , run )
88+ cliCmd , fileCmd , err := buildRunnerCmds (modules )
9789 if err != nil {
9890 return err
9991 }
10092
101- preRunE := func (_ * cobra.Command , _ []string ) error {
102- if duration < 1 * time .Second {
103- return errors .New ("duration must be at least 1 second" )
104- }
105-
106- if reportPath == "" {
107- return errors .New ("report path cannot be empty" )
108- }
109-
110- stat , err := os .Stat (reportPath ) //nolint:govet // shad
111- if err == nil && stat .IsDir () {
112- return errors .New ("report path cannot be a directory" )
113- }
114-
115- return nil
116- }
117- cliCmd .PreRunE = preRunE
118-
11993 rootCmd .AddCommand (
12094 cliCmd ,
95+ fileCmd ,
12196 & cobra.Command {
12297 Use : gen .FlagsetName ,
12398 Short : "Generate a test model file." ,
12499 RunE : func (_ * cobra.Command , args []string ) error {
125100 return gen .Generate (args , modules )
126101 },
127102 },
128- & cobra.Command {
129- Use : file .FlagsetName ,
130- Short : "Run from a test model file." ,
131- PreRunE : preRunE ,
132- RunE : func (_ * cobra.Command , args []string ) error {
133- meta , err := file .Parse (args , modules ) //nolint:govet // shad
134- if err != nil {
135- return err
136- }
137-
138- return run (meta )
139- },
140- },
141103 )
142104
143105 return rootCmd .Execute ()
@@ -148,76 +110,62 @@ func Run(modules module.Modules) error {
148110// or when the test duration runs out. Will immediately exit if any module
149111// returns an error from its call to Run().
150112func run (metadata module.Metadata ) error {
151- startLogger .Info ("Starting modules" )
113+ zerologr .Info ("Starting modules" )
152114
153115 if err := startModules (metadata ); err != nil {
154- startLogger .Error (err , "Start failure" )
116+ zerologr .Error (err , "Start failure" )
155117 return err
156118 }
157- startLogger .Info ("All modules started" )
119+ zerologr .Info ("All modules started" )
158120
159121 reporter := setupReporter (metadata )
160122
123+ // Start signal interceptor for SIGINT and SIGTERM
124+ signalCtx , signalCancel := signal .NotifyContext (context .Background (), syscall .SIGINT , syscall .SIGTERM )
125+ defer signalCancel ()
126+
161127 // Start traffic and monitor, with a timeout of: test >duration<
162- background := context .Background ()
163- deadlineCtx , deadlineCancel := context .WithTimeout (background , duration )
164- defer deadlineCancel ()
165- startLogger .Info ("Traffic will run for: " + duration .String ())
166-
167- // Separate the reporter context to allow for finishing reporting separately from stopping traffic.
168- reporterCtx , reporterCancel := context .WithCancel (background )
169- defer reporterCancel ()
170- reporter .Start (reporterCtx )
171-
172- // Suppress log output while the TUI is active to prevent interference
173- // with the alternate-screen renderer.
174- if interactive {
175- zerologr .Set (logr .Discard ())
176- startLogger = logr .Discard ()
177- }
128+ timeoutCtx , timeoutCancel := context .WithTimeout (signalCtx , duration )
129+ defer timeoutCancel ()
130+ zerologr .Info ("Traffic will run for: " + duration .String ())
131+
132+ reporter .Start (signalCtx )
178133
179- if err := traffic .Run (deadlineCtx , metadata , reporter ); err != nil {
180- startLogger .Error (err , "Failed to start traffic" )
134+ if err := traffic .Run (timeoutCtx , metadata , reporter ); err != nil {
135+ zerologr .Error (err , "Failed to start traffic" )
181136 return err
182137 }
183138
184- // Start signal interceptor for SIGINT and SIGTERM
185- signalCtx , signalCancel := signal .NotifyContext (background , syscall .SIGINT , syscall .SIGTERM )
186- defer signalCancel ()
187- startLogger .Info ("Awaiting stop signal" )
139+ zerologr .Info ("Awaiting completion (SIGINT/SIGTERM or duration timeout)" )
188140 select {
189141 case <- signalCtx .Done ():
190- startLogger .Info ("Got stop signal" )
191- case <- deadlineCtx .Done ():
192- startLogger .Info ("Deadline exceeded" )
142+ zerologr .Info ("Got stop signal" )
143+ case <- timeoutCtx .Done ():
144+ zerologr .Info ("Deadline exceeded" )
193145 }
194- deadlineCancel ()
195- signalCancel ()
196-
197- startLogger = startLogger .WithName ("stopping" )
198146
147+ // stopErr accumulates any errors from stopping traffic and modules, and finalising the report,
148+ // to be returned at the end of the function.
199149 var stopErr error
200- stopErr = traffic .Stop ()
201- if stopErr != nil {
202- startLogger . Error ( stopErr , "Error when stopping traffic" )
150+ if stopErr = traffic .Stop (); stopErr != nil {
151+ zerologr . Error ( stopErr , "Error when stopping traffic" )
152+ stopErr = fmt . Errorf ( "%w: traffic stop: %w" , ErrStopping , stopErr )
203153 }
154+ signalCancel () // Cancel the signal context to unblock the reporter if it's waiting on it.
204155
205- // Stop it here to allow the scheduler to report all before shutting down.
206- reporterCancel ()
207-
208- startLogger .Info ("Stopping modules" )
156+ zerologr .Info ("Stopping modules" )
209157 for _ , m := range metadata {
210158 if moduleStopErr := m .Stop (); moduleStopErr != nil {
211- startLogger .Error (moduleStopErr , "Module stop reported an error" , "module" , m .Name ())
212- stopErr = errors .Join (stopErr , fmt .Errorf ("module %s: %w" , m .Name (), moduleStopErr ))
159+ zerologr .Error (moduleStopErr , "Module stop reported an error" , "module" , m .Name ())
160+ stopErr = errors .Join (stopErr , fmt .Errorf ("module %s stop : %w" , m .Name (), moduleStopErr ))
213161 }
214162 }
215163
216- startLogger .Info ("Finalising report" )
164+ zerologr .Info ("Finalising report" )
217165 reporterStopErr := reporter .Finalise ()
218166 if reporterStopErr != nil {
219- startLogger .Error (reporterStopErr , "Error when finalising report" )
220- stopErr = errors .Join (stopErr , reporterStopErr )
167+ zerologr .Error (reporterStopErr , "Error when finalising report" )
168+ stopErr = errors .Join (stopErr , fmt . Errorf ( "reporter stop: %w" , reporterStopErr ) )
221169 }
222170
223171 return stopErr
@@ -226,7 +174,7 @@ func run(metadata module.Metadata) error {
226174// Starts the input modules and logs any errors.
227175func startModules (meta []* module.Meta ) error {
228176 for _ , m := range meta {
229- startLogger .Info ("Starting" , "module" , m .Name ())
177+ zerologr .Info ("Starting" , "module" , m .Name ())
230178 if err := m .Run (); err != nil {
231179 return fmt .Errorf ("failed to start module %s: %w" , m .Name (), err )
232180 }
@@ -248,3 +196,84 @@ func setupReporter(metadata module.Metadata) report.Reporter {
248196
249197 return yamlR
250198}
199+
200+ func buildRunnerCmds (modules module.Modules ) (* cobra.Command , * cobra.Command , error ) {
201+ // The runner flagset is passed to cli and file commands that run tests.
202+ runnerFlagSet := buildRunnerFlagSet ()
203+
204+ cliCmd , err := cli .NewCommand (modules , run )
205+ if err != nil {
206+ return nil , nil , err
207+ }
208+ cliCmd .Flags ().AddFlagSet (runnerFlagSet )
209+
210+ runnerPreRunE := func (_ * cobra.Command , _ []string ) error {
211+ if duration < 1 * time .Second {
212+ return errors .New ("duration must be at least 1 second" )
213+ }
214+
215+ if reportPath == "" {
216+ return errors .New ("report path cannot be empty" )
217+ }
218+
219+ // err is fine since the file does not have to exist prior to the test ending.
220+ stat , err := os .Stat (reportPath ) //nolint:govet // shad
221+ if err == nil && stat .IsDir () {
222+ return errors .New ("report path cannot be a directory" )
223+ }
224+
225+ if interactive {
226+ // Suppress log output while the TUI is active to prevent interference
227+ // TODO: create file sink instead to be able to capture logs in interactive mode as well
228+ zerologr .Set (logr .Discard ())
229+ } else {
230+ zerologr .Set (zerologr .New (& zerologr.Opts {Console : true , Caller : true }).WithName ("arbiter" ))
231+ }
232+
233+ return nil
234+ }
235+ cliCmd .PreRunE = runnerPreRunE
236+
237+ fileCmd := & cobra.Command {
238+ Use : file .FlagsetName ,
239+ Short : "Run from a test model file." ,
240+ PreRunE : runnerPreRunE ,
241+ RunE : func (_ * cobra.Command , args []string ) error {
242+ meta , err := file .Parse (args , modules ) //nolint:govet // shad
243+ if err != nil {
244+ return err
245+ }
246+
247+ return run (meta )
248+ },
249+ }
250+ fileCmd .Flags ().AddFlagSet (runnerFlagSet )
251+
252+ return cliCmd , fileCmd , nil
253+ }
254+
255+ func buildRunnerFlagSet () * pflag.FlagSet {
256+ runnerFlagSet := & pflag.FlagSet {}
257+ runnerFlagSet .DurationVarP (
258+ & duration ,
259+ "duration" ,
260+ "d" ,
261+ defaultDuration ,
262+ "The duration of the test run, minimum 1 second." ,
263+ )
264+ runnerFlagSet .StringVarP (
265+ & reportPath ,
266+ "report-path" ,
267+ "r" ,
268+ defaultReportPath ,
269+ "Path to the final report." ,
270+ )
271+ runnerFlagSet .BoolVarP (
272+ & interactive ,
273+ "interactive" ,
274+ "i" ,
275+ defaultInteractive ,
276+ "Start in interactive TUI mode with a live progress bar and per-operation statistics." ,
277+ )
278+ return runnerFlagSet
279+ }
0 commit comments