wsdirector-cli 0.4.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/wsdirector DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "rubygems"
4
-
5
- $:.unshift(File.expand_path(__dir__ + "/../lib"))
6
-
7
- require "wsdirector/cli"
8
-
9
- begin
10
- WSDirector::CLI.new.tap { |cli| cli.run }
11
- rescue => e
12
- STDERR.puts e.message
13
- STDERR.puts e.backtrace.join("\n")
14
- exit 1
15
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "optparse"
4
-
5
- require "wsdirector"
6
- require "wsdirector/scenario_reader"
7
- require "wsdirector/runner"
8
-
9
- module WSDirector
10
- # Command line interface for WsDirector
11
- class CLI
12
- def initialize
13
- end
14
-
15
- def run
16
- parse_args!
17
-
18
- begin
19
- require "colorize" if WSDirector.config.colorize?
20
- rescue LoadError
21
- WSDirector.config.colorize = false
22
- warn "Install colorize to use colored output"
23
- end
24
-
25
- scenario = WSDirector::ScenarioReader.parse(
26
- WSDirector.config.scenario_path
27
- )
28
-
29
- if WSDirector::Runner.new(scenario).start
30
- exit 0
31
- else
32
- exit 1
33
- end
34
- rescue Error => e
35
- warn e.message
36
- exit 1
37
- end
38
-
39
- private
40
-
41
- def parse_args!
42
- # rubocop: disable Metrics/LineLength
43
- parser = OptionParser.new do |opts|
44
- opts.banner = "Usage: wsdirector scenario_path ws_url [options]"
45
-
46
- opts.on("-s SCALE", "--scale=SCALE", Integer, "Scale factor") do |v|
47
- WSDirector.config.scale = v
48
- end
49
-
50
- opts.on("-t TIMEOUT", "--timeout=TIMEOUT", Integer, "Synchronization (wait_all) timeout") do |v|
51
- WSDirector.config.sync_timeout = v
52
- end
53
-
54
- opts.on("-c", "--[no-]color", "Colorize output") do |v|
55
- WSDirector.config.colorize = v
56
- end
57
-
58
- opts.on("-v", "--version", "Print versin") do
59
- $stdout.puts WSDirector::VERSION
60
- exit 0
61
- end
62
- end
63
- # rubocop: enable Metrics/LineLength
64
-
65
- parser.parse!
66
-
67
- WSDirector.config.scenario_path = ARGV[0]
68
- WSDirector.config.ws_url = ARGV[1]
69
-
70
- raise(Error, "Scenario path is missing") if WSDirector.config.scenario_path.nil?
71
-
72
- raise(Error, "File doesn't exist #{WSDirector.config.scenario_path}") unless
73
- File.file?(WSDirector.config.scenario_path)
74
-
75
- raise(Error, "Websocket server url is missing") if WSDirector.config.ws_url.nil?
76
- end
77
- end
78
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "websocket-client-simple"
4
- require "securerandom"
5
-
6
- module WSDirector
7
- # WebSocket client
8
- class Client
9
- WAIT_WHEN_EXPECTING_EVENT = 5
10
-
11
- attr_reader :ws, :id
12
-
13
- # Create new WebSocket client and connect to WSDirector
14
- # ws URL.
15
- #
16
- # Optionally provide an ignore pattern (to ignore incoming message,
17
- # for example, pings)
18
- def initialize(ignore: nil)
19
- @ignore = ignore
20
- has_messages = @has_messages = Concurrent::Semaphore.new(0)
21
- messages = @messages = Queue.new
22
- path = WSDirector.config.ws_url
23
- open = Concurrent::Promise.new
24
- client = self
25
-
26
- @id = SecureRandom.hex(6)
27
- @ws = WebSocket::Client::Simple.connect(path) do |ws|
28
- ws.on(:open) do |_event|
29
- open.set(true)
30
- end
31
-
32
- ws.on :message do |msg|
33
- data = msg.data
34
- next if data.empty?
35
- next if client.ignored?(data)
36
- messages << data
37
- has_messages.release
38
- end
39
-
40
- ws.on :error do |e|
41
- messages << Error.new("WebSocket Error #{e.inspect} #{e.backtrace}")
42
- end
43
- end
44
-
45
- open.wait!(WAIT_WHEN_EXPECTING_EVENT)
46
- rescue Errno::ECONNREFUSED
47
- raise Error, "Failed to connect to #{path}"
48
- end
49
-
50
- def receive(timeout = WAIT_WHEN_EXPECTING_EVENT)
51
- @has_messages.try_acquire(1, timeout)
52
- msg = @messages.pop(true)
53
- raise msg if msg.is_a?(Exception)
54
- msg
55
- end
56
-
57
- def send(msg)
58
- @ws.send(msg)
59
- end
60
-
61
- def ignored?(msg)
62
- return false unless @ignore
63
- @ignore.any? { |pattern| msg =~ pattern }
64
- end
65
- end
66
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # Acts as a re-usable global barrier for a fixed number of clients.
5
- # Barrier is reset if sucessfully passed in time.
6
- class ClientsHolder
7
- def initialize(count)
8
- @barrier = Concurrent::CyclicBarrier.new(count)
9
- end
10
-
11
- def wait_all
12
- result = barrier.wait(WSDirector.config.sync_timeout)
13
- raise Error, "Timeout (#{WSDirector.config.sync_timeout}s) exceeded for #wait_all" unless
14
- result
15
- barrier.reset
16
- result
17
- end
18
-
19
- private
20
-
21
- attr_reader :barrier
22
- end
23
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # WSDirector configuration
5
- class Configuration
6
- attr_accessor :ws_url, :scenario_path, :colorize, :scale,
7
- :sync_timeout
8
-
9
- def initialize
10
- reset!
11
- end
12
-
13
- def colorize?
14
- colorize == true
15
- end
16
-
17
- # Restore to defaults
18
- def reset!
19
- @scale = 1
20
- @colorize = false
21
- @sync_timeout = 5
22
- end
23
- end
24
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- module Ext
5
- # Extend Object through refinements
6
- module DeepDup
7
- refine ::Hash do
8
- # Based on ActiveSupport http://api.rubyonrails.org/classes/Hash.html#method-i-deep_dup
9
- def deep_dup
10
- each_with_object(dup) do |(key, value), hash|
11
- hash[key] = if value.is_a?(::Hash) || value.is_a?(::Array)
12
- value.deep_dup
13
- else
14
- value
15
- end
16
- end
17
- end
18
- end
19
-
20
- refine ::Array do
21
- # From ActiveSupport http://api.rubyonrails.org/classes/Array.html#method-i-deep_dup
22
- def deep_dup
23
- map do |value|
24
- if value.is_a?(::Hash) || value.is_a?(::Array)
25
- value.deep_dup
26
- else
27
- value
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end
34
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # Print messages (optionally colorized) to STDOUT
5
- class Printer
6
- def self.out(message, color = nil)
7
- message = message.colorize(color) if WSDirector.config.colorize? && color
8
- $stdout.puts(message)
9
- end
10
- end
11
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- module Protocols
5
- # ActionCable protocol
6
- class ActionCable < Base
7
- WELCOME_MSG = {type: "welcome"}.to_json
8
- PING_IGNORE = /['"]type['"]:\s*['"]ping['"]/
9
-
10
- # Add ping ignore and make sure that we receive Welcome message
11
- def init_client(**options)
12
- options[:ignore] ||= [PING_IGNORE]
13
-
14
- super(**options)
15
-
16
- receive("data" => WELCOME_MSG)
17
- end
18
-
19
- def subscribe(step)
20
- identifier = extract_identifier(step)
21
-
22
- client.send({command: "subscribe", identifier: identifier}.to_json)
23
-
24
- begin
25
- receive(
26
- "data" => {"type" => "confirm_subscription", "identifier" => identifier}
27
- )
28
- rescue UnmatchedExpectationError => e
29
- raise unless /reject_subscription/.match?(e.message)
30
- raise UnmatchedExpectationError, "Subscription rejected to #{identifier}"
31
- end
32
- end
33
-
34
- def perform(step)
35
- identifier = extract_identifier(step)
36
- action = step.delete("action")
37
-
38
- raise Error, "Action is missing" unless action
39
-
40
- data = step.fetch("data", {}).merge(action: action).to_json
41
-
42
- client.send({command: "message", data: data, identifier: identifier}.to_json)
43
- end
44
-
45
- def receive(step)
46
- return super unless step.key?("channel")
47
-
48
- identifier = extract_identifier(step)
49
- message = step.fetch("data", {})
50
- super("data" => {"identifier" => identifier, "message" => message})
51
- end
52
-
53
- def receive_all(step)
54
- messages = step["messages"]
55
-
56
- return super if messages.nil? || messages.empty?
57
-
58
- messages.each do |msg|
59
- next unless msg.key?("channel")
60
- identifier = extract_identifier(msg)
61
- msg["data"] = {"identifier" => identifier, "message" => msg["data"]}
62
- end
63
-
64
- super
65
- end
66
-
67
- private
68
-
69
- def extract_identifier(step)
70
- channel = step.delete("channel")
71
- step.fetch("params", {}).merge(channel: channel).to_json
72
- end
73
- end
74
- end
75
- end
@@ -1,135 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module WSDirector
6
- module Protocols
7
- # Base protocol describes basic actions
8
- class Base
9
- include WSDirector::Utils
10
-
11
- def initialize(task)
12
- @task = task
13
- end
14
-
15
- def init_client(**options)
16
- @client = build_client(**options)
17
- end
18
-
19
- def handle_step(step)
20
- type = step.delete("type")
21
- raise Error, "Unknown step: #{type}" unless respond_to?(type)
22
-
23
- return unless task.sampled?(step)
24
-
25
- public_send(type, step)
26
- end
27
-
28
- # Sleeps for a specified number of seconds.
29
- #
30
- # If "shift" is provided than the initial value is
31
- # shifted by random number from (-shift, shift).
32
- #
33
- # Set "debug" to true to print the delay time.
34
- def sleep(step)
35
- delay = step.fetch("time").to_f
36
- shift = step.fetch("shift", 0).to_f
37
-
38
- delay = delay - shift * rand + shift * rand
39
-
40
- print("Sleep for #{delay}s") if step.fetch("debug", false)
41
-
42
- Kernel.sleep delay if delay > 0
43
- end
44
-
45
- # Prints provided message
46
- def debug(step)
47
- print(step.fetch("message"))
48
- end
49
-
50
- def receive(step)
51
- expected = step.fetch("data")
52
- received = client.receive
53
- raise UnmatchedExpectationError, prepare_receive_error(expected, received) unless
54
- receive_matches?(expected, received)
55
- rescue ThreadError
56
- raise NoMessageError, "Expected to receive #{expected} but nothing has been received"
57
- end
58
-
59
- # rubocop: disable Metrics/CyclomaticComplexity
60
- def receive_all(step)
61
- messages = step.delete("messages")
62
- raise ArgumentError, "Messages array must be specified" if
63
- messages.nil? || messages.empty?
64
-
65
- expected =
66
- Hash[messages.map do |msg|
67
- multiplier = parse_multiplier(msg.delete("multiplier") || "1")
68
- [msg["data"], multiplier]
69
- end]
70
-
71
- total_expected = expected.values.sum
72
- total_received = 0
73
-
74
- total_expected.times do
75
- received = client.receive
76
-
77
- total_received += 1
78
-
79
- match = expected.find { |k, _| receive_matches?(k, received) }
80
-
81
- raise UnexpectedMessageError, "Unexpected message received: #{received}" if
82
- match.nil?
83
-
84
- expected[match.first] -= 1
85
- expected.delete(match.first) if expected[match.first].zero?
86
- end
87
- rescue ThreadError
88
- raise NoMessageError,
89
- "Expected to receive #{total_expected} messages " \
90
- "but received only #{total_received}"
91
- end
92
- # rubocop: enable Metrics/CyclomaticComplexity
93
-
94
- def send(step)
95
- data = step.fetch("data")
96
- data = JSON.generate(data) if data.is_a?(Hash)
97
- client.send(data)
98
- end
99
-
100
- def wait_all(_step)
101
- task.global_holder.wait_all
102
- end
103
-
104
- def to_proc
105
- proc { |step| handle_step(step) }
106
- end
107
-
108
- private
109
-
110
- attr_reader :client, :task
111
-
112
- def build_client(**options)
113
- Client.new(**options)
114
- end
115
-
116
- def receive_matches?(expected, received)
117
- received = JSON.parse(received) if expected.is_a?(Hash)
118
-
119
- received == expected
120
- end
121
-
122
- def prepare_receive_error(expected, received)
123
- <<~MSG
124
- Action failed: #receive
125
- -- expected: #{expected}
126
- ++ got: #{received}
127
- MSG
128
- end
129
-
130
- def print(msg)
131
- $stdout.puts "DEBUG #{Time.now.iso8601} client=#{client.id} #{msg}\n"
132
- end
133
- end
134
- end
135
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "wsdirector/protocols/base"
4
- require "wsdirector/protocols/action_cable"
5
-
6
- module WSDirector
7
- ID2CLASS = {
8
- "base" => "Base",
9
- "action_cable" => "ActionCable"
10
- }.freeze
11
-
12
- module Protocols # :nodoc:
13
- # Raised when received not expected message
14
- class UnmatchedExpectationError < WSDirector::Error; end
15
- # Raised when received message is unexpected
16
- class UnexpectedMessageError < WSDirector::Error; end
17
- # Raised when nothing has been received
18
- class NoMessageError < WSDirector::Error; end
19
-
20
- class << self
21
- def get(id)
22
- raise Error, "Unknown protocol: #{id}" unless ID2CLASS.key?(id)
23
- class_name = ID2CLASS.fetch(id)
24
- const_get(class_name)
25
- end
26
- end
27
- end
28
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # Handle results from all clients from the group
5
- class Result
6
- attr_reader :group, :errors
7
-
8
- def initialize(group)
9
- @group = group
10
- @errors = Concurrent::Array.new
11
-
12
- @all = Concurrent::AtomicFixnum.new(0)
13
- @failures = Concurrent::AtomicFixnum.new(0)
14
-
15
- @sampling_mutex = Mutex.new
16
- @sampling_counter = Hash.new { |h, k| h[k] = 0 }
17
- end
18
-
19
- # Called when client successfully finished it's work
20
- def succeed
21
- all.increment
22
- end
23
-
24
- # Called when client failed
25
- def failed(error_message)
26
- errors << error_message
27
- all.increment
28
- failures.increment
29
- end
30
-
31
- def success?
32
- failures.value.zero?
33
- end
34
-
35
- def total_count
36
- all.value
37
- end
38
-
39
- def failures_count
40
- failures.value
41
- end
42
-
43
- def track_sample(id, max)
44
- sampling_mutex.synchronize do
45
- return false if sampling_counter[id] >= max
46
-
47
- sampling_counter[id] += 1
48
- true
49
- end
50
- end
51
-
52
- private
53
-
54
- attr_reader :all, :success, :failures, :sampling_counter, :sampling_mutex
55
- end
56
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "wsdirector/printer"
4
-
5
- module WSDirector
6
- # Holds all results for all groups of clients
7
- class ResultsHolder
8
- def initialize
9
- @groups = Concurrent::Map.new
10
- end
11
-
12
- def success?
13
- @groups.values.all?(&:success?)
14
- end
15
-
16
- def print_summary
17
- single_group = groups.size == 1
18
- groups.each do |group, result|
19
- color = result.success? ? :green : :red
20
- prefix = single_group ? "" : "Group #{group}: "
21
- Printer.out(
22
- "#{prefix}#{result.total_count} clients, #{result.failures_count} failures\n",
23
- color
24
- )
25
-
26
- unless result.success?
27
- print_errors(result.errors)
28
- Printer.out "\n"
29
- end
30
- end
31
- end
32
-
33
- def <<(result)
34
- groups[result.group] = result
35
- end
36
-
37
- private
38
-
39
- attr_reader :groups
40
-
41
- def print_errors(errors)
42
- Printer.out "\n"
43
- errors.each.with_index do |error, i|
44
- Printer.out "#{i + 1}) #{error}\n", :red
45
- end
46
- end
47
- end
48
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "wsdirector/clients_holder"
4
- require "wsdirector/results_holder"
5
- require "wsdirector/result"
6
- require "wsdirector/task"
7
- require "wsdirector/ext/deep_dup"
8
-
9
- module WSDirector
10
- # Initiates all clients as a separate tasks (=threads)
11
- class Runner
12
- using WSDirector::Ext::DeepDup
13
-
14
- def initialize(scenario)
15
- @scenario = scenario
16
- @total_count = scenario["total"]
17
- @global_holder = ClientsHolder.new(total_count)
18
- @results_holder = ResultsHolder.new
19
- end
20
-
21
- def start
22
- Thread.abort_on_exception = true
23
-
24
- tasks = scenario["clients"].flat_map do |client|
25
- result = Result.new(client.fetch("name"))
26
- results_holder << result
27
-
28
- Array.new(client.fetch("multiplier")) do
29
- Thread.new do
30
- Task.new(client.deep_dup, global_holder: global_holder, result: result)
31
- .run
32
- end
33
- end
34
- end
35
-
36
- tasks.each(&:join)
37
- results_holder.print_summary
38
- results_holder.success?
39
- end
40
-
41
- private
42
-
43
- attr_reader :scenario, :total_count, :global_holder, :results_holder
44
- end
45
- end