collins_state 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ source :rubygems
2
+
3
+ Encoding.default_external = Encoding::UTF_8
4
+ gem 'collins_client', '~> 0.2.7'
5
+ gem 'escape', '~> 0.0.4'
6
+
7
+ group :development do
8
+ gem "rspec", "~> 2.10.0"
9
+ gem "yard", "~> 0.8"
10
+ gem 'redcarpet'
11
+ gem 'webmock'
12
+ gem "bundler", "~> 1.1.4"
13
+ gem "jeweler", "~> 1.8.4"
14
+ gem 'simplecov'
15
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,55 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ addressable (2.3.2)
5
+ collins_client (0.2.7)
6
+ httparty (~> 0.8.3)
7
+ crack (0.3.1)
8
+ diff-lcs (1.1.3)
9
+ escape (0.0.4)
10
+ git (1.2.5)
11
+ httparty (0.8.3)
12
+ multi_json (~> 1.0)
13
+ multi_xml
14
+ jeweler (1.8.4)
15
+ bundler (~> 1.0)
16
+ git (>= 1.2.5)
17
+ rake
18
+ rdoc
19
+ json (1.7.5)
20
+ multi_json (1.3.6)
21
+ multi_xml (0.5.1)
22
+ rake (0.9.2.2)
23
+ rdoc (3.12)
24
+ json (~> 1.4)
25
+ redcarpet (2.2.2)
26
+ rspec (2.10.0)
27
+ rspec-core (~> 2.10.0)
28
+ rspec-expectations (~> 2.10.0)
29
+ rspec-mocks (~> 2.10.0)
30
+ rspec-core (2.10.1)
31
+ rspec-expectations (2.10.0)
32
+ diff-lcs (~> 1.1.3)
33
+ rspec-mocks (2.10.1)
34
+ simplecov (0.7.1)
35
+ multi_json (~> 1.0)
36
+ simplecov-html (~> 0.7.1)
37
+ simplecov-html (0.7.1)
38
+ webmock (1.8.11)
39
+ addressable (>= 2.2.7)
40
+ crack (>= 0.1.7)
41
+ yard (0.8.3)
42
+
43
+ PLATFORMS
44
+ ruby
45
+
46
+ DEPENDENCIES
47
+ bundler (~> 1.1.4)
48
+ collins_client (~> 0.2.7)
49
+ escape (~> 0.0.4)
50
+ jeweler (~> 1.8.4)
51
+ redcarpet
52
+ rspec (~> 2.10.0)
53
+ simplecov
54
+ webmock
55
+ yard (~> 0.8)
data/README.md ADDED
@@ -0,0 +1,6 @@
1
+ # collins_state
2
+
3
+ Collins state is a simple state management framework built on top of collins.
4
+
5
+ Take a look at some of the specs or the provisioning workflow for some sample
6
+ code.
data/Rakefile ADDED
@@ -0,0 +1,67 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ jeweler = Jeweler::Tasks.new do |gem|
16
+ gem.name = "collins_state"
17
+ gem.homepage = "https://github.com/tumblr/collins/tree/master/support/ruby/collins-state"
18
+ gem.license = "APL 2.0"
19
+ gem.summary = %Q{Collins based state management}
20
+ gem.description = %Q{Provides basic framework for managing stateful processes with collins}
21
+ gem.email = "bmatheny@tumblr.com"
22
+ gem.authors = ["Blake Matheny"]
23
+ gem.files.exclude "spec/**/*"
24
+ gem.files.exclude '.gitignore'
25
+ gem.files.exclude '.rspec'
26
+ gem.files.exclude '.rvmrc'
27
+ gem.add_runtime_dependency 'collins_client', '~> 0.2.7'
28
+ gem.add_runtime_dependency 'escape', '~> 0.0.4'
29
+ end
30
+
31
+ task :help do
32
+ puts("rake -T # See available rake tasks")
33
+ puts("rake publish # generate gemspec, build it, push it to repo")
34
+ puts("rake version:bump:patch # Bump patch number")
35
+ puts("rake all # bump patch and publish")
36
+ puts("rake # Run tests")
37
+ end
38
+
39
+ task :publish => [:gemspec, :build] do
40
+ package_abs = jeweler.jeweler.gemspec_helper.gem_path
41
+ package_name = File.basename(package_abs)
42
+
43
+ ["repo.tumblr.net","repo.ewr01.tumblr.net"].each do |host|
44
+ puts("Copying #{package_abs} to #{host} and installing, you may be prompted for your password")
45
+ system "scp #{package_abs} #{host}:"
46
+ system "ssh -t #{host} 'sudo tumblr_gem install #{package_name}'"
47
+ end
48
+ end
49
+
50
+ task :all => ["version:bump:patch", :publish] do
51
+ puts("Done!")
52
+ end
53
+
54
+ require 'rspec/core'
55
+ require 'rspec/core/rake_task'
56
+ RSpec::Core::RakeTask.new(:spec) do |spec|
57
+ spec.fail_on_error = false
58
+ spec.pattern = FileList['spec/**/*_spec.rb']
59
+ end
60
+
61
+ task :default => :spec
62
+
63
+ require 'yard'
64
+ YARD::Rake::YardocTask.new do |t|
65
+ t.files = ['lib/**/*.rb']
66
+ t.options = ['--markup', 'markdown']
67
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.10
@@ -0,0 +1,53 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "collins_state"
8
+ s.version = "0.2.10"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Blake Matheny"]
12
+ s.date = "2012-10-31"
13
+ s.description = "Provides basic framework for managing stateful processes with collins"
14
+ s.email = "bmatheny@tumblr.com"
15
+ s.extra_rdoc_files = [
16
+ "README.md"
17
+ ]
18
+ s.files = [
19
+ "Gemfile",
20
+ "Gemfile.lock",
21
+ "README.md",
22
+ "Rakefile",
23
+ "VERSION",
24
+ "collins_state.gemspec",
25
+ "lib/collins/persistent_state.rb",
26
+ "lib/collins/state/mixin.rb",
27
+ "lib/collins/state/mixin_class_methods.rb",
28
+ "lib/collins/state/specification.rb",
29
+ "lib/collins/workflows/provisioning_workflow.rb",
30
+ "lib/collins_state.rb"
31
+ ]
32
+ s.homepage = "https://github.com/tumblr/collins/tree/master/support/ruby/collins-state"
33
+ s.licenses = ["APL 2.0"]
34
+ s.require_paths = ["lib"]
35
+ s.rubygems_version = "1.8.24"
36
+ s.summary = "Collins based state management"
37
+
38
+ if s.respond_to? :specification_version then
39
+ s.specification_version = 3
40
+
41
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
42
+ s.add_runtime_dependency(%q<collins_client>, ["~> 0.2.7"])
43
+ s.add_runtime_dependency(%q<escape>, ["~> 0.0.4"])
44
+ else
45
+ s.add_dependency(%q<collins_client>, ["~> 0.2.7"])
46
+ s.add_dependency(%q<escape>, ["~> 0.0.4"])
47
+ end
48
+ else
49
+ s.add_dependency(%q<collins_client>, ["~> 0.2.7"])
50
+ s.add_dependency(%q<escape>, ["~> 0.0.4"])
51
+ end
52
+ end
53
+
@@ -0,0 +1,108 @@
1
+ require 'collins/state/mixin'
2
+ require 'escape'
3
+
4
+ module Collins
5
+
6
+ # Provides state management via collins tags
7
+ class PersistentState
8
+
9
+ include ::Collins::State::Mixin
10
+ include ::Collins::Util
11
+
12
+ attr_reader :collins_client, :path, :exec_type, :logger
13
+
14
+ def initialize collins_client, options = {}
15
+ @collins_client = collins_client
16
+ @exec_type = :client
17
+ @logger = get_logger({:logger => collins_client.logger}.merge(options).merge(:progname => 'Collins_PersistentState'))
18
+ end
19
+
20
+ def run
21
+ self.class.managed_state(collins_client)
22
+ self
23
+ end
24
+
25
+ # @deprecated Use {#use_netcat} instead. Replace in 0.3.0
26
+ def use_curl path = nil
27
+ use_netcat(path)
28
+ end
29
+
30
+ def use_client
31
+ @path = nil
32
+ @exec_type = :client
33
+ self
34
+ end
35
+
36
+ def use_netcat path = nil
37
+ @path = Collins::Option(path).get_or_else("nc")
38
+ @exec_type = :netcat
39
+ self
40
+ end
41
+
42
+ # @override update_asset(asset, key, spec)
43
+ def update_asset asset, key, spec
44
+ username = collins_client.username
45
+ password = collins_client.password
46
+ host = collins_client.host
47
+ tag = ::Collins::Util.get_asset_or_tag(asset).tag
48
+ case @exec_type
49
+ when :netcat
50
+ netcat_command = [path, '-i', '1'] + get_hostname_port(host)
51
+ timestamp_padding, json = format_spec_for_netcat spec
52
+ body = "attribute=#{key};#{json}"
53
+ length = body.size + timestamp_padding
54
+ request = [request_line(tag)] + request_headers(username, password, length)
55
+ request_string = request.join("\\r\\n") + "\\r\\n\\r\\n" + body
56
+ current_time = 'TIMESTAMP=$(' + get_time_cmds(host).join(' | ') + ')'
57
+ args = ['printf', request_string]
58
+ "#{current_time}\n" + Escape.shell_command(args) + ' $TIMESTAMP | ' + Escape.shell_command(netcat_command)
59
+ else
60
+ super(asset, key, spec)
61
+ end
62
+ end
63
+
64
+ def get_time_cmds host
65
+ hostname, port = get_hostname_port host
66
+ get_cmd = %q{printf 'GET /api/timestamp HTTP/1.0\r\n\r\n'}
67
+ nc = sprintf('%s -i 1 %s %d', path, hostname, port)
68
+ only_time = %q{grep -e '^[0-9]'}
69
+ last_line = %q{tail -n 1}
70
+ [get_cmd, nc, only_time, last_line]
71
+ end
72
+
73
+ def request_line tag
74
+ "POST /api/asset/#{tag} HTTP/1.0"
75
+ end
76
+
77
+ def request_headers username, password, length
78
+ [
79
+ "User-Agent: collins_state",
80
+ auth_header(username, password),
81
+ "Content-Type: application/x-www-form-urlencoded",
82
+ "Content-Length: #{length}",
83
+ "Connection: Close"
84
+ ]
85
+ end
86
+
87
+ def auth_header username, password
88
+ auth = Base64.strict_encode64("#{username}:#{password}")
89
+ "Authorization: Basic #{auth}"
90
+ end
91
+
92
+ # @return [Array<Fixnum,String>] padding for format string and json to use
93
+ def format_spec_for_netcat spec
94
+ expected_timestamp_size = Time.now.utc.to_i.to_s.size
95
+ spec.timestamp = '%s'
96
+ actual_timestamp_size = spec.timestamp.to_s.size
97
+ timestamp_padding = (expected_timestamp_size - actual_timestamp_size).abs
98
+ json = spec.to_json
99
+ [timestamp_padding, json]
100
+ end
101
+ # @return [Array<String,String>] hostname and port
102
+ def get_hostname_port host
103
+ host = URI.parse(host)
104
+ [host.host, host.port.to_s]
105
+ end
106
+ end
107
+
108
+ end
@@ -0,0 +1,435 @@
1
+ require 'collins/state/mixin_class_methods'
2
+ require 'collins/state/specification'
3
+ require 'json'
4
+
5
+ module Collins; module State; module Mixin
6
+
7
+ class << self
8
+ # Classes that include Mixin will also be extended by ClassMethods
9
+ def included(base)
10
+ base.extend Collins::State::Mixin::ClassMethods
11
+ end
12
+ end
13
+
14
+ # @abstract Classes mixing this in must supply a collins client
15
+ # @return [Collins::Client] collins client
16
+ # @raise [NotImplementedError] if not specified
17
+ def collins_client
18
+ raise NotImplementedError.new("no collins client available")
19
+ end
20
+
21
+ # @abstract Classes mixing this in must supply a logger
22
+ # @return [Logger] logger instance
23
+ # @raise [NotImplementedError] if not specified
24
+ def logger
25
+ raise NotImplementedError.new("no logger available")
26
+ end
27
+
28
+ # The attribute name used for storing the sate value
29
+ # @note we append _json to the managed_state_name since it will serialize this way
30
+ # @return [String] the key to use for storing the state value on an asset
31
+ def attribute_name
32
+ self.class.managed_state_name.to_s + "_json"
33
+ end
34
+
35
+ # Has the specified asset state expired?
36
+ #
37
+ # Read the state from the specified asset, and determine whether the state is expired yet or not.
38
+ # The timestamp + expiration is compared to now.
39
+ #
40
+ # @note This method will return true if no specification is associated with the asset
41
+ # @param [Collins::Asset] asset The asset to look at
42
+ # @return [Boolean] True if the specification has expired, false otherwise
43
+ def expired? asset
44
+ specification_expired?(state_specification(asset))
45
+ end
46
+
47
+ # Whether we are done processing or not
48
+ # @param [Collins::Asset] asset
49
+ # @return [Boolean] whether asset is in done state or not
50
+ def finished? asset
51
+ state_specification(asset).to_option.map do |spec|
52
+ specification_expired?(spec) && event(spec.name)[:terminus]
53
+ end.get_or_else(false)
54
+ end
55
+
56
+ # The things that would be done if the asset was transitioned
57
+ # @param [Collins::Asset] asset
58
+ # @return [Array<Array<Symbol,String>>] array of arrays. Each sub array has two elements
59
+ def plan asset
60
+ plans = []
61
+ state_specification(asset).to_option.map { |specification|
62
+ event(specification.name).to_option.map { |ev|
63
+ if not specification_expired?(specification) then
64
+ plans << [:noop, "not yet expired"]
65
+ else
66
+ if ev[:transition] then
67
+ event(ev[:transition]).to_option.map { |ev2|
68
+ plans << [:event, ev2.name]
69
+ }.get_or_else {
70
+ plans << [:exception, "invalid event name #{ev[:transition]}"]
71
+ }
72
+ elsif not ev[:on_transition] then
73
+ plans << [:noop, "no transition specified, and no on_transition action specified"]
74
+ end
75
+ if ev[:on_transition] then
76
+ action(ev[:on_transition]).to_option.map { |ae|
77
+ plans << [:action, ae.name]
78
+ }.get_or_else {
79
+ plans << [:exception, "invalid action specified #{ev[:on_transition]}"]
80
+ }
81
+ end
82
+ end
83
+ }.get_or_else {
84
+ plans << [:exception, "invalid event name #{e.name}"]
85
+ }
86
+ }.get_or_else {
87
+ Collins::Option(initial).map { |init|
88
+ event(init).to_option.map { |ev|
89
+ if ev[:before_transition] then
90
+ action(ev[:before_transition]).to_option.map do |act|
91
+ plans << [:action, act.name]
92
+ end.get_or_else {
93
+ plans << [:exception, "action #{ev[:before_transition]} not defined"]
94
+ }
95
+ end
96
+ plans << [:event, init]
97
+ }.get_or_else {
98
+ plans << [:exception, "initial state #{init} is undefined"]
99
+ }
100
+ }.get_or_else {
101
+ plans << [:exception, "no initial state defined"]
102
+ }
103
+ }
104
+ plans
105
+ end
106
+
107
+ # Reset (delete) the attribute once the process is complete
108
+ #
109
+ # @param [Collins::Asset] asset The asset on which to delete the attribute
110
+ # @return [Boolean] True if the value was successfully deleted
111
+ def reset! asset
112
+ collins_client.delete_attribute! asset, attribute_name
113
+ end
114
+
115
+ # Return the name of the current sate. Will be :None if not initialized
116
+ # @param [Collins::Asset] asset
117
+ # @return [Symbol] state name
118
+ def state_name asset
119
+ state_specification(asset).name
120
+ end
121
+
122
+ # Get the state specification associated with the asset
123
+ #
124
+ # @param [Collins::Asset] asset The asset to retrieve
125
+ # @return [Collins::State::Specification] The spec (be sure to check `defined?`)
126
+ def state_specification asset
127
+ updated = asset_from_cache asset
128
+ result = updated.send(attribute_name.to_sym)
129
+ if result then
130
+ res = JSON.parse(result) rescue nil
131
+ if res.nil? then # for backwards compatibility
132
+ res = JSON.parse(result, :create_additions => false)
133
+ res = ::Collins::State::Specification.json_create(res) if (res.is_a?(Hash) and res.key?('data'))
134
+ end
135
+ if res.is_a?(::Collins::State::Specification) then
136
+ res
137
+ else
138
+ logger.warn("Could not deserialize #{result} to a State Specification")
139
+ ::Collins::State::Specification.empty
140
+ end
141
+ else
142
+ ::Collins::State::Specification.empty
143
+ end
144
+ end
145
+
146
+ # Transition the asset to the next appropriate state
147
+ #
148
+ # This method will either initialize the state on the specified asset (if needed), or process the
149
+ # current state. If processing the current state, the expiration time will be checked, followed by
150
+ # running any specified transition event, followed by any `on_transition` action. The transition
151
+ # event is run before the `on_transition` action, because the `on_transition` action should only be
152
+ # called if we have successfully transitioned to a new state. In the event that the transition
153
+ # event has a `before_transition` defined that fails, we don't want to execute the `on_transition`
154
+ # code since we have not yet successfully transitioned.
155
+ #
156
+ # @param [Collins::Asset] asset The asset to transition
157
+ # @param [Hash] options Transition options
158
+ # @option options [Boolean] :quiet Don't throw an exception if a transition fails
159
+ # @raise [CollinsError] if state needs to be initialized and no `:initial` key is found in the manage_state options hash
160
+ # @raise [CollinsError] if a specification is found on the asset, but the named state isn't found as a registered event
161
+ # @raise [CollinsError] if an action is specified as a `:before_transition` or `:on_transition` value but not registered
162
+ # @return [Collins::State::Specification] The current state, after the transition is run
163
+ def transition asset, options = {}
164
+ state_specification(asset).to_option.map { |specification|
165
+ event(specification.name).to_option.or_else {
166
+ raise CollinsError.new("no event defined with name #{specification.name}")
167
+ }.filter { |e| specification_expired?(specification) }.map { |e|
168
+ if e[:transition] then
169
+ spec = run_event(asset, e[:transition], options)
170
+ run_action(asset, e[:on_transition]) if e[:on_transition]
171
+ # If we transitioned and no expiration is set, rerun
172
+ if specification_expired?(spec) and spec.name != e.name then
173
+ transition(asset, options)
174
+ else
175
+ spec
176
+ end
177
+ else
178
+ logger.debug("No transition event specified for #{e.name}")
179
+ run_action(asset, e[:on_transition], :log => true) if e[:on_transition]
180
+ specification
181
+ end
182
+ }.get_or_else {
183
+ logger.trace("Specification #{specification.name} not yet expired")
184
+ specification
185
+ }
186
+ }.get_or_else {
187
+ init = Collins::Option(initial).get_or_else {
188
+ raise Collins::CollinsError.new("no initial state defined for transition")
189
+ }
190
+ options = Collins::Option(self.class.managed_state_options).get_or_else({})
191
+ run_event(asset, init, options)
192
+ }
193
+ end
194
+
195
+ # Allow registered events to be executed. Allow predicate calls to respond true if the asset is
196
+ # currently in the specified state or false otherwise
197
+ def method_missing method, *args, &block
198
+ if args.length == 0 then
199
+ return super
200
+ end
201
+ asset = args[0]
202
+ options = args[1]
203
+ if not (asset.is_a?(Collins::Asset) || asset.is_a?(String)) then
204
+ return super
205
+ end
206
+ if not options.nil? and not options.is_a?(Hash) then
207
+ return super
208
+ elsif options.nil? then
209
+ options = {}
210
+ end
211
+ question_only = method.to_s.end_with?('?')
212
+ if question_only then
213
+ meth = method.to_s[0..-2].to_sym # drop ? at end
214
+ if event?(meth) then
215
+ state_name(asset) == meth
216
+ else
217
+ false
218
+ end
219
+ elsif event?(method) then
220
+ run_event(asset, method, options)
221
+ else
222
+ super
223
+ end
224
+ end # method_missing
225
+
226
+ def respond_to? method
227
+ question_only = method.to_s.end_with?('?')
228
+ if question_only then
229
+ method = method.to_s[0..-2] # drop? at end
230
+ end
231
+ if not event?(method) then
232
+ super
233
+ else
234
+ true
235
+ end
236
+ end # respond_to?
237
+
238
+ protected
239
+ # Get the callback associated with the specified action name
240
+ # @param [Symbol] name Action name
241
+ # @return [Collins::SimpleCallback] always returns, check `defined?`
242
+ def action name
243
+ name_sym = name.to_sym
244
+ self.class.actions.fetch(name_sym, ::Collins::SimpleCallback.empty)
245
+ end
246
+
247
+ # True if an event with the given name is registered, false otherwise
248
+ # @param [Symbol] name Event name
249
+ # @return [Boolean] Registered or not
250
+ def event? name
251
+ event(name).defined?
252
+ end
253
+
254
+ # Get the callback associated with the specified event name
255
+ # @param [Symbol] name Event name
256
+ # @return [Collins::SimpleCallback] always returns, check `defined?`
257
+ def event name
258
+ name_sym = name.to_sym
259
+ self.class.events.fetch(name_sym, ::Collins::SimpleCallback.empty)
260
+ end
261
+
262
+ # Get the expires time associated with the specified event
263
+ #
264
+ # @param [Symbol] name Name of the event
265
+ # @param [Fixnum] default Value if event not found or expires not specified
266
+ # @return [Fixnum] An integer representing the number of seconds before expiration
267
+ def event_expires name, default = 0
268
+ event(name).to_option.map do |e|
269
+ e.options.fetch(:expires, default.to_i)
270
+ end.get_or_else(default.to_i)
271
+ end
272
+
273
+ # Initial event or nil
274
+ def initial
275
+ Collins::Option(self.class.managed_state_options).
276
+ filter{|h| h.key?(:initial)}.
277
+ map{|h| h[:initial]}.
278
+ get_or_else(nil)
279
+ end
280
+
281
+ # Run the specified action
282
+ #
283
+ # @param [Collins::Asset] asset the asset to run the action for
284
+ # @param [Symbol] name the action name
285
+ # @param [Hash] options
286
+ # @option options [Boolean] :log (true) log action run
287
+ # @return [Boolean] the result of executing the action
288
+ def run_action asset, name, options = {}
289
+ action(name).to_option.map do |a|
290
+ result = case a.arity
291
+ when 2
292
+ a.call(asset, self)
293
+ else
294
+ a.call(asset)
295
+ end
296
+ log_run_action(asset, name, result) if options.fetch(:log, false)
297
+ result
298
+ end.get_or_else {
299
+ raise CollinsError.new("Action #{name} not defined")
300
+ }
301
+ end
302
+
303
+ # Update the asset spec that the action was run
304
+ # @param [Collins::Asset] asset
305
+ # @param [Symbol] action_name
306
+ # @param [Boolean] result of the action that was run
307
+ # @return [Collins::State::Specification]
308
+ def log_run_action asset, action_name, result
309
+ current_spec = state_specification asset
310
+ count = current_spec.fetch(:log, []).size
311
+ log = Hash[:count => count, :timestamp => Time.now.utc.to_i, :name => action_name, :result => result]
312
+ current_spec.<<(:log, log)
313
+ asset_cache_delete asset
314
+ update_asset asset, attribute_name, current_spec
315
+ current_spec
316
+ end
317
+
318
+ # Run the specified event, and execute :before_transition if specified
319
+ #
320
+ # @param [Collins::Asset] asset the asset to process the event for
321
+ # @param [Symbol] name the name of the event to process
322
+ # @param [Hash] options Option for executing the event
323
+ # @option options [Boolean] :quiet Do not throw an exception if an error occurs running actions
324
+ # @return [Collins::State::Specification] the new state the asset is in
325
+ def run_event asset, name, options = {}
326
+ event(name).to_option.map do |e|
327
+ update_state(asset, e, options)
328
+ end.get_or_else {
329
+ raise CollinsError.new("Event #{name} not defined")
330
+ }
331
+ end
332
+
333
+ # Whether the given specification has expired or not
334
+ #
335
+ # This method is primarily useful for testing whether a stored specification has expired yet or
336
+ # not. If a specification has just been created (and has an expiration set), this will obviously
337
+ # return false.
338
+ #
339
+ # @param [Collins::State::Specification] specification
340
+ # @return [Boolean] true if expired, false otherwise
341
+ def specification_expired? specification
342
+ timestamp = specification.timestamp
343
+ name = specification.name
344
+ expires_at = timestamp + event_expires(name)
345
+ # Must be >= otherwise 0 + Time.now as expires_at will fail
346
+ Time.now.utc.to_i >= expires_at
347
+ end
348
+
349
+ # Update asset state using the specified event information
350
+ #
351
+ # This method also handles executing appropriate transition related actions and managing failures.
352
+ # The actual code for updating the asset itself is in #update_asset
353
+ #
354
+ # @param [Collins::Asset] asset the asset to update
355
+ # @param [Collins::SimpleCallback] event the event
356
+ # @param [Hash] options Option for executing the event
357
+ # @option options [Boolean] :quiet Do not throw an exception if an error occurs running actions
358
+ def update_state asset, event, options = {}
359
+ if event[:before_transition] then
360
+ run_before_transition asset, event[:before_transition], event, options
361
+ else
362
+ specification = Collins::State::Specification.new event.name, event[:desc], Time.now
363
+ specification = specification.merge(state_specification(asset))
364
+ asset_cache_delete asset
365
+ res = update_asset asset, attribute_name, specification
366
+ # Horrible hack to allow update_asset to be overridden such that it can provide a string
367
+ # for performing an update, in which case we need the actual command
368
+ if res.is_a?(String) then
369
+ return res
370
+ else
371
+ return specification
372
+ end
373
+ end
374
+ end
375
+
376
+ # Run the specified before_transition
377
+ # We try running the specified action, and if successful update the asset with the specification.
378
+ # If running the action fails, we update the attempts count on the current specification and
379
+ # either throw an exception (default behavior) or return the updated spec (options[:quiet] ==
380
+ # true). On success we return the new specification.
381
+ def run_before_transition asset, action_name, event, options
382
+ before_result =
383
+ begin
384
+ run_action(asset, action_name)
385
+ rescue Exception => e
386
+ logger.warn("Failed running #{action_name}: #{e}")
387
+ false
388
+ end
389
+ if before_result != false
390
+ specification = Collins::State::Specification.new event.name, event[:desc], Time.now
391
+ specification = specification.merge(state_specification(asset))
392
+ asset_cache_delete asset
393
+ update_asset asset, attribute_name, specification
394
+ specification
395
+ else
396
+ current_spec = state_specification(asset)
397
+ count = current_spec.fetch(:attempts, []).size
398
+ attempts = Hash[:timestamp => Time.now.utc.to_i, :count => count, :name => event.name]
399
+ current_spec.<<(:attempts, attempts)
400
+ asset_cache_delete asset
401
+ update_asset asset, attribute_name, current_spec
402
+ if options.fetch(:quiet, false) then
403
+ return current_spec
404
+ else
405
+ raise CollinsError.new("Can't transition from #{current_spec.name} to #{event.name}, before_transition failed")
406
+ end
407
+ end
408
+ end
409
+
410
+ # Update the asset using the specified key and JSON
411
+ #
412
+ # This method is broken out this way so it can be easily overriden
413
+ # @param [Collins::Asset] asset The asset to update
414
+ # @param [String] key The attribute to update on the asset
415
+ # @param [Specification] spec The state specification to set as the value associated with the key
416
+ # @return [Boolean] indication of success or failure
417
+ def update_asset asset, key, spec
418
+ collins_client.set_attribute! asset, key, spec.to_json
419
+ end
420
+
421
+ private
422
+ def asset_cache_delete asset
423
+ tag = Collins::Util.get_asset_or_tag(asset).tag
424
+ @_asset_cache.delete(tag) if (@_asset_cache && @_asset_cache.key?(tag))
425
+ end
426
+ def asset_from_cache asset
427
+ tag = Collins::Util.get_asset_or_tag(asset).tag
428
+ @_asset_cache = {} unless @_asset_cache
429
+ if not @_asset_cache.key?(tag) then
430
+ @_asset_cache[tag] = collins_client.get(tag)
431
+ end
432
+ @_asset_cache[tag]
433
+ end
434
+
435
+ end; end; end
@@ -0,0 +1,128 @@
1
+ require 'collins/simple_callback'
2
+
3
+ module Collins; module State; module Mixin
4
+
5
+ # Static (Class) methods to be added to classes that mixin this module. These methods provide
6
+ # functionality for registering new actions and events, along with access to the managed state.
7
+ # This is accomplished via class instance variables which allow each class that include the
8
+ # {Collins::State::Mixin} its own managed state and variables.
9
+ module ClassMethods
10
+
11
+ # @return [Hash<Symbol, Collins::SimpleCallback>] Registered actions for the managed state
12
+ attr_reader :actions
13
+
14
+ # @return [Hash<Symbol, Collins::SimpleCallback>] Registered events for the managed state
15
+ attr_reader :events
16
+
17
+ # Register a managed state for this class
18
+ #
19
+ # @note Only one managed state per class is allowed.
20
+ # @param [Symbol] name The name of the managed state, e.g. `:some_process`
21
+ # @param [Hash] options Managed state options
22
+ # @option options [Symbol] :initial First event to fire if the state is being initialized
23
+ # @yieldparam [Collins::Client] block Managed state description, registering events and actions
24
+ #
25
+ # @example
26
+ # manage_state :a_process, :initial => :start do |client|
27
+ # action :log_it do |asset|
28
+ # client.log! asset, "Did some logging"
29
+ # end
30
+ # event :start, :desc => 'Initial state', :expires => after(30, :minutes), :on_transition => :log_it, :transition => :done
31
+ # event :done, :desc => 'Done'
32
+ # end
33
+ def manage_state name, options = {}, &block
34
+ @actions = {}
35
+ @events = {}
36
+ @managed_state = ::Collins::SimpleCallback.new(name, options, block)
37
+ end
38
+
39
+ # @return [String] Name of managed state associated with this class
40
+ def managed_state_name
41
+ @managed_state.name
42
+ end
43
+
44
+ # @return [Hash] Options associated with this managed state
45
+ def managed_state_options
46
+ @managed_state.options
47
+ end
48
+
49
+ # Get and execute the managed state associated with this class
50
+ #
51
+ # @see {#manage_state}
52
+ # @param [Collins::Client] client Collins client instance
53
+ def managed_state client
54
+ @managed_state.call(client)
55
+ end
56
+
57
+ # Register a named action for use as a transition execution target
58
+ #
59
+ # Actions are called when specified by a `:before_transition` or `:on_transition` option to an
60
+ # event. Options are not currently used.
61
+ #
62
+ # @param [Symbol] name Action name
63
+ # @param [Hash] options Action options
64
+ # @yieldparam [Collins::Asset] block Given an asset, perform the specified action
65
+ # @yieldreturn [Boolean,Object] indicates success or failure of operation. Only `false` (or an exception) indicate failure. Other return values are fine.
66
+ # @return [Collins::SimpleCallback] the callback created for the action
67
+ def action name, options = {}, &block
68
+ name_sym = name.to_sym
69
+ new_action = ::Collins::SimpleCallback.new(name_sym, options, block)
70
+ @actions[name_sym] = new_action
71
+ new_action
72
+ end
73
+
74
+ # Register a named event associated with the managed state
75
+ #
76
+ # Events are typically called as method invocations on the class, or as the result of a state
77
+ # being expired and a transition being specified.
78
+ #
79
+ # The example (and events in general) can be read as: Execute `:before_transition` before
80
+ # successfully transitioning to this event. Once transitioned, after `:expires` is reached the
81
+ # `:on_transition` action should be called, followed by the event associated with the specified
82
+ # `:transition`.
83
+ #
84
+ # @param [Symbol] name Event name
85
+ # @param [Hash] options Event options
86
+ # @option options [Symbol] :before_transition An action to execute successfully before transitioning to this state
87
+ # @option options [String] :desc A description of the event, required
88
+ # @option options [#to_i] :expires Do not consider `:on_transition` or `:transition` until after this amount of time
89
+ # @option options [Symbol] :on_transition Once the expiration time has passed execute this action
90
+ # @option options [Symbol] :transition The event to call after its appropriate for transition (due to timeout and successful `:before` calls)
91
+ # @return [Collins::SimpleCallback] the callback created for the action
92
+ #
93
+ # @example
94
+ # event :stuff, :desc => 'I do things', :before_transition => :try_action, :expires => after(5, :minutes),
95
+ # :transition => :after_stuff_event, :on_transition => :stuff_action
96
+ #
97
+ # @note A transition will not occur unless the :before_transition is successful (does not return false or throw an exception)
98
+ # @raise [KeyError] if the options hash is missing a `:desc` key
99
+ def event name, options = {}
100
+ name_sym = name.to_sym
101
+ ::Collins::Option(options[:desc]).or_else {
102
+ raise KeyError.new("Event #{name} is missing :desc key")
103
+ }
104
+ new_event = ::Collins::SimpleCallback.new(name_sym, options)
105
+ @events[name_sym] = new_event
106
+ new_event
107
+ end
108
+
109
+ # Convert a `Fixnum` into seconds based on the specified `time_unit`
110
+ #
111
+ # @param [Fixnum] duration Time value
112
+ # @param [Symbol] time_unit Unit of time, one of `:hour`, `:hours`, `:minute`, `:minutes`.
113
+ # @return [Fixnum] Value in seconds
114
+ def after duration, time_unit = :seconds
115
+ multiplier = case time_unit
116
+ when :hours, :hour
117
+ 60*60
118
+ when :minutes, :minute
119
+ 60
120
+ else
121
+ 1
122
+ end
123
+ multiplier * duration.to_i
124
+ end
125
+
126
+ end # Collins::State::Mixin::ClassMethods
127
+
128
+ end; end; end
@@ -0,0 +1,192 @@
1
+ require 'json'
2
+
3
+ module Collins; module State
4
+
5
+ # Represents a managed state
6
+ #
7
+ # Modeling a state machine like process in collins is useful for multi-step processes such as
8
+ # decommissioning hardware (where you want the process to span several days, with several
9
+ # discrete steps), or monitoring some process and taking action. A
10
+ # {Specification} provides a common format for storing state information
11
+ # as a value of an asset.
12
+ #
13
+ # This will rarely be used directly, but rather is a byproduct of using
14
+ # {Collins::State::Mixin}
15
+ class Specification
16
+ include ::Collins::Util
17
+
18
+ # Used as name placeholder when unspecified
19
+ EMPTY_NAME = :None
20
+ # Used as description placeholder when unspecified
21
+ EMPTY_DESCRIPTION = "Unspecified"
22
+
23
+ # Create an empty specification
24
+ # @return [Collins::State::Specification] spec
25
+ def self.empty
26
+ ::Collins::State::Specification.new :none => true
27
+ end
28
+
29
+ # Create an instance from JSON data
30
+ #
31
+ # @note This method is required by the JSON module for deserialization
32
+ # @param [Hash] json JSON data
33
+ # @return [Collins::State::Specification] spec
34
+ def self.json_create json
35
+ ::Collins::State::Specification.new json['data']
36
+ end
37
+
38
+ # @return [Symbol] Name of the specification.
39
+ # @see Collins::State::Mixin::ClassMethods#event
40
+ # @note This is a unique key and should not change.
41
+ attr_reader :name
42
+
43
+ # @return [String] State description, for humans
44
+ # @see Collins::State::Mixin::ClassMethods#event
45
+ attr_reader :description
46
+
47
+ # @return [Fixnum] Unixtime, UTC, when this state was entered
48
+ attr_accessor :timestamp
49
+
50
+ # @return [Hash] Additional meta-data
51
+ attr_reader :extras
52
+
53
+ # Instantiate a new Specification
54
+ #
55
+ # @param [Hash,(Symbol,String,Fixnum)] args Arguments for instantiation
56
+ # @option args [String,Symbol] :name The name of the specification
57
+ # @option args [String] :description A description of the specification
58
+ # @option args [Fixnum,Time,String] :timestamp (Time.at(0).utcto_i) The time the event occurred
59
+ #
60
+ # @example
61
+ # Specification.new :start, 'I am a state', Time.now
62
+ # Specification.new :start, :description => 'Hello World', :timestamp => 0
63
+ #
64
+ # @note If the specified timestamp is not a `Fixnum` (unixtime), the value is converted to a fixnum
65
+ # @raise [ArgumentError] when `timestamp` is not a `Time`, `String` or `Fixnum`
66
+ # @raise [ArgumentError] when `name` or `description` not specified
67
+ def initialize *args
68
+ opts = {}
69
+ while arg = args.shift do
70
+ if arg.is_a?(Hash) then
71
+ opts.update(arg)
72
+ else
73
+ key = [:name, :description, :timestamp].select{|k| !opts.key?(k)}.first
74
+ opts.update(key => arg) unless key.nil?
75
+ end
76
+ end
77
+ opts = symbolize_hash(opts)
78
+
79
+ if opts.fetch(:none, false) then
80
+ @name = EMPTY_NAME
81
+ @description = EMPTY_DESCRIPTION
82
+ else
83
+ @name = ::Collins::Option(opts.delete(:name)).map{|s| s.to_sym}.get_or_else {
84
+ raise ArgumentError.new("Name not specified")
85
+ }
86
+ @description = ::Collins::Option(opts.delete(:description)).get_or_else {
87
+ raise ArgumentError.new("Description not specified")
88
+ }
89
+ end
90
+ ts = ::Collins::Option(opts.delete(:timestamp)).get_or_else(Time.at(0))
91
+ @timestamp = parse_timestamp(ts)
92
+ # Flatten if needed
93
+ if opts.key?(:extras) then
94
+ @extras = opts[:extras]
95
+ else
96
+ @extras = opts
97
+ end
98
+ end
99
+
100
+ # merges appropriate extras from the other spec into this one
101
+ # @param [Collins::State::Specification] other
102
+ def merge other
103
+ ext = other.extras.merge(@extras)
104
+ ext.delete(:none)
105
+ @extras = ext
106
+ self
107
+ end
108
+
109
+ # @return [Boolean] Indicate whether Specification is empty or not
110
+ def empty?
111
+ !self.defined?
112
+ end
113
+
114
+ # @return [Boolean] Indicate whether Specification is defined or not
115
+ def defined?
116
+ @name != EMPTY_NAME || @description != EMPTY_DESCRIPTION
117
+ end
118
+
119
+ # @return [Collins::Option] None if undefined/empty
120
+ def to_option
121
+ if self.defined? then
122
+ ::Collins::Some(self)
123
+ else
124
+ ::Collins::None()
125
+ end
126
+ end
127
+
128
+ def [](key)
129
+ @extras[key.to_sym]
130
+ end
131
+ def key?(key)
132
+ @extras.key?(key.to_sym)
133
+ end
134
+ def fetch(key, default)
135
+ @extras.fetch(key.to_sym, default)
136
+ end
137
+ def []=(key, value)
138
+ @extras[key.to_sym] = value
139
+ end
140
+
141
+ def <<(key, value)
142
+ @extras[key.to_sym] = [] unless @extras.key?(key.to_sym)
143
+ @extras[key.to_sym] << value
144
+ @extras[key.to_sym]
145
+ end
146
+
147
+ # Convert this instance to JSON
148
+ #
149
+ # @note this is required by the JSON module
150
+ # @return [String] JSON string representation of object
151
+ def to_json(*a)
152
+ {
153
+ 'json_class' => self.class.name,
154
+ 'data' => to_hash
155
+ }.to_json(*a)
156
+ end
157
+
158
+ # @return [Hash] Hash representation of data
159
+ def to_hash
160
+ h = Hash[:name => name, :description => description, :timestamp => timestamp]
161
+ h[:extras] = extras unless extras.empty?
162
+ h
163
+ end
164
+
165
+ # @return [String] human readable
166
+ def to_s
167
+ "Specification(name = #{name}, description = #{description}, timestamp = #{timestamp}, extras = #{extras})"
168
+ end
169
+
170
+ # Mostly used for testing
171
+ def ==(other)
172
+ (other.class == self.class) &&
173
+ other.name == self.name &&
174
+ other.timestamp == self.timestamp
175
+ end
176
+
177
+ private
178
+ def parse_timestamp ts
179
+ if ts.is_a?(String) then
180
+ ts.to_s.to_i
181
+ elsif ts.is_a?(Time) then
182
+ ts.utc.to_i
183
+ elsif ts.is_a?(Fixnum) then
184
+ ts
185
+ else
186
+ raise ArgumentError.new("timestamp is not a String, Time, or Fixnum")
187
+ end
188
+ end
189
+
190
+ end # class Specification
191
+
192
+ end; end
@@ -0,0 +1,57 @@
1
+ require 'collins_state'
2
+
3
+ module Collins
4
+ class ProvisioningWorkflow < ::Collins::PersistentState
5
+
6
+ manage_state :provisioning_process, :initial => :start do |client|
7
+
8
+ action :reboot_hard do |asset, p|
9
+ p.logger.warn "Calling rebootHard on asset #{Collins::Util.get_asset_or_tag(asset).tag}"
10
+ client.power! asset, "rebootHard"
11
+ end
12
+
13
+ action :reprovision do |asset, p|
14
+ detailed = client.get asset
15
+ p.logger.warn "Reprovisioning #{detailed.tag}"
16
+ client.set_status! asset, "Maintenance"
17
+ client.provision asset, detailed.nodeclass, detailed.contact, :suffix => detailed.suffix,
18
+ :primary_role => detailed.primary_role, :secondary_role => detailed.secondary_role,
19
+ :pool => detailed.pool
20
+ end
21
+
22
+ action :toggle_status do |asset, p|
23
+ tag = Collins::Util.get_asset_or_tag(asset).tag
24
+ p.logger.warn "Toggling status (Provisioning then Provisioned) for #{tag}"
25
+ client.set_status!(asset, 'Provisioning') && client.set_status!(asset, 'Provisioned')
26
+ end
27
+
28
+ # No transitions are defined here, since they are managed fully asynchronously by the
29
+ # supervisor process. We only defines actions to take once the timeout has passed. Each
30
+ # part of the process makes an event call, the supervisor process just continuously does a
31
+ # collins.managed_process("ProvisioningWorkflow").transition(asset) which will
32
+ # either execute the on_transition action due to expiration or do nothing.
33
+
34
+ # After 30 minutes reprovision if still in the start state - ewr_start_provisioning - collins.managed_process("ProvisioningWorkflow").start(asset)
35
+ event :start, :desc => 'Provisioning Started', :expires => after(30, :minutes), :on_transition => :reprovision
36
+ # After 10 minutes if we haven't seen an ipxe request, reprovision (possible failed move) - vlan changer & provisioning status - collins.mp.vlan_moved_to_provisioning(asset)
37
+ event :vlan_moved_to_provisioning, :desc => 'Moved to provisioning VLAN', :expires => after(15, :minutes), :on_transition => :reprovision
38
+ # After 5 minutes if we haven't yet seen a kickstart request, reboot (possibly stuck at boot) - phil ipxe
39
+ event :ipxe_seen, :desc => 'Asset has made iPXE request to Phil', :expires => after(5, :minutes), :on_transition => :reboot_hard
40
+ # After 5 minutes if the kickstart process hasn't begun, reboot (possibly stuck at boot) - phil kickstart
41
+ event :kickstart_seen, :desc => 'Asset has made kickstart request to Phil', :expires => after(15, :minutes), :on_transition => :reboot_hard
42
+ # After 45 minutes if we haven't gotten to post, reboot to reinstall - phil kickstart pre
43
+ event :kickstart_started, :desc => 'Asset has started kickstart process', :expires => after(45, :minutes), :on_transition => :reboot_hard
44
+ # After 45 minutes if the kickstart process hasn't completed, reboot to reinstall - phil kickstart post
45
+ event :kickstart_post_started, :desc => 'Asset has started kickstart post section', :expires => after(45, :minutes), :on_transition => :reboot_hard
46
+ # After 10 minutes if we haven't been moved to the production VLAN, toggle our status to get us moved - phil kickstart post
47
+ event :kickstart_finished, :desc => 'Asset has finished kickstart process', :expires => after(15, :minutes), :on_transition => :toggle_status
48
+ # After another 15 minutes if still not moved toggle our status again - vlan changer when discover=true
49
+ event :vlan_move_to_production, :desc => 'Moved to production VLAN', :expires => after(15, :minutes), :on_transition => :toggle_status
50
+ # After another 10 minutes if we're not reachable by IP, toggle the status - supervisor
51
+ event :reachable_by_ip, :desc => 'Now reachable by IP address', :expires => after(10, :minutes), :on_transition => :toggle_status
52
+ # Place holder for when we finish
53
+ event :done, :desc => 'Finished', :terminus => true
54
+ end
55
+ end
56
+
57
+ end
@@ -0,0 +1,4 @@
1
+ $:.unshift File.join File.dirname(__FILE__)
2
+ require 'collins_client'
3
+ require 'collins/persistent_state'
4
+ Dir[File.join(File.dirname(__FILE__), 'collins', 'workflows', '*.rb')].each {|f| require f}
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: collins_state
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.10
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Blake Matheny
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-10-31 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: collins_client
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.2.7
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 0.2.7
30
+ - !ruby/object:Gem::Dependency
31
+ name: escape
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 0.0.4
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 0.0.4
46
+ description: Provides basic framework for managing stateful processes with collins
47
+ email: bmatheny@tumblr.com
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files:
51
+ - README.md
52
+ files:
53
+ - Gemfile
54
+ - Gemfile.lock
55
+ - README.md
56
+ - Rakefile
57
+ - VERSION
58
+ - collins_state.gemspec
59
+ - lib/collins/persistent_state.rb
60
+ - lib/collins/state/mixin.rb
61
+ - lib/collins/state/mixin_class_methods.rb
62
+ - lib/collins/state/specification.rb
63
+ - lib/collins/workflows/provisioning_workflow.rb
64
+ - lib/collins_state.rb
65
+ homepage: https://github.com/tumblr/collins/tree/master/support/ruby/collins-state
66
+ licenses:
67
+ - APL 2.0
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ segments:
79
+ - 0
80
+ hash: -3397090300883848228
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubyforge_project:
89
+ rubygems_version: 1.8.24
90
+ signing_key:
91
+ specification_version: 3
92
+ summary: Collins based state management
93
+ test_files: []