cult 0.1.3.pre → 0.1.4.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +53 -47
  3. data/cult.gemspec +6 -5
  4. data/exe/cult +19 -4
  5. data/lib/cult/cli/common.rb +1 -2
  6. data/lib/cult/cli/console_cmd.rb +2 -2
  7. data/lib/cult/cli/cri_extensions.rb +66 -13
  8. data/lib/cult/cli/init_cmd.rb +6 -7
  9. data/lib/cult/cli/node_cmd.rb +233 -67
  10. data/lib/cult/cli/provider_cmd.rb +16 -13
  11. data/lib/cult/cli/role_cmd.rb +25 -26
  12. data/lib/cult/cli/task_cmd.rb +13 -13
  13. data/lib/cult/commander.rb +53 -17
  14. data/lib/cult/commander_sync.rb +29 -0
  15. data/lib/cult/definition.rb +21 -49
  16. data/lib/cult/driver.rb +1 -1
  17. data/lib/cult/drivers/common.rb +12 -11
  18. data/lib/cult/drivers/digital_ocean_driver.rb +2 -2
  19. data/lib/cult/drivers/virtual_box_driver.rb +156 -0
  20. data/lib/cult/drivers/vultr_driver.rb +3 -3
  21. data/lib/cult/named_array.rb +103 -15
  22. data/lib/cult/node.rb +139 -12
  23. data/lib/cult/paramap.rb +209 -0
  24. data/lib/cult/project.rb +2 -17
  25. data/lib/cult/provider.rb +3 -1
  26. data/lib/cult/role.rb +12 -8
  27. data/lib/cult/task.rb +73 -45
  28. data/lib/cult/template.rb +3 -4
  29. data/lib/cult/transaction.rb +11 -5
  30. data/lib/cult/user_refinements.rb +1 -1
  31. data/lib/cult/version.rb +1 -1
  32. data/lib/cult.rb +32 -3
  33. data/skel/roles/{all → base}/role.json +0 -0
  34. data/skel/roles/{all/tasks/00000-do-something-cool → base/tasks/000-do-something-cool} +0 -0
  35. data/skel/roles/{all/tasks/sync → base/tasks/sync-host-map} +5 -5
  36. data/skel/roles/base/tasks/sync-leader-of +11 -0
  37. data/skel/roles/bootstrap/files/cult-motd +15 -3
  38. data/skel/roles/bootstrap/tasks/{00000-set-hostname → 000-set-hostname} +1 -1
  39. data/skel/roles/bootstrap/tasks/{00001-add-cult-user → 001-add-cult-user} +0 -6
  40. data/skel/roles/bootstrap/tasks/002-disable-root-user +7 -0
  41. data/skel/roles/bootstrap/tasks/{00002-install-cult-motd → 002-install-cult-motd} +1 -1
  42. metadata +29 -11
  43. data/lib/cult/cli/fleet_cmd.rb +0 -37
@@ -24,11 +24,48 @@ module Cult
24
24
  # This maps #named_array_identifier to #name by default
25
25
  module ObjectExtensions
26
26
  def named_array_identifier
27
- name
27
+ respond_to?(:name) ? name : nil
28
28
  end
29
29
  ::Object.include(self)
30
30
  end
31
31
 
32
+ # Allows named_array.all[/something/]
33
+ class IndexWrapper
34
+ def initialize(ary, method_name)
35
+ @ary, @method_name = ary, method_name
36
+ end
37
+
38
+ def inspect
39
+ "\#<#{self.class.name}>"
40
+ end
41
+
42
+ def [](*args)
43
+ @ary.send(@method_name, *args)
44
+ end
45
+
46
+ def to_a
47
+ @ary
48
+ end
49
+ alias_method :to_ary, :to_a
50
+
51
+ def to_named_array
52
+ @ary.to_named_array
53
+ end
54
+ end
55
+ private_constant :IndexWrapper
56
+
57
+ def self.indexable_wrapper(method_name)
58
+ old_method_name = "#{method_name}_without_wrapper"
59
+ alias_method old_method_name, method_name
60
+ define_method(method_name) do |*a|
61
+ if a.empty?
62
+ return IndexWrapper.new(self, method_name)
63
+ else
64
+ return send(old_method_name, *a)
65
+ end
66
+ end
67
+ end
68
+
32
69
 
33
70
  def to_named_array
34
71
  self
@@ -39,7 +76,7 @@ module Cult
39
76
  # and wrap the result with a NamedArray. This is why NamedArray.select
40
77
  # results in a NamedArray instead of an Array
41
78
  PROXY_METHODS = %i(& * + - << | collect compact flatten reject reverse
42
- rotate select shuffle slice sort uniq)
79
+ rotate select shuffle slice sort uniq sort_by)
43
80
  PROXY_METHODS.each do |method_name|
44
81
  define_method(method_name) do |*args, &b|
45
82
  r = super(*args, &b)
@@ -87,41 +124,85 @@ module Cult
87
124
  when NilClass
88
125
  nil
89
126
  else
90
- fail KeyError, "Invalid predicate: #{predicate.inspect}"
127
+ predicate
91
128
  end
92
129
  end
93
130
  private :expand_predicate
94
131
 
132
+ def extract_index(key)
133
+ re = /\[\s*([^\]]*)\s*\]$/
134
+ if key.is_a?(String) && (m = key.match(re))
135
+ subs, expr = m[0], m[1]
136
+ index = case expr
137
+ when /^(\-?\d+)$/; $1.to_i #.. $1.to_i
138
+ when /^(\-?\d+)\s*\.\.\s*(\-?\d+)$/; $1.to_i .. $2.to_i
139
+ when /^(\-?\d+)\s*\.\.\.\s*(\-?\d+)$/; $1.to_i ... $2.to_i
140
+ when /^((?:\-?\d+\s*,?\s*)+)$/; $1.split(',').map(&:to_i)
141
+ end
142
+ # We return [predicate string with index removed, expanded index]
143
+ [ key[0 ... key.size - subs.size], index ]
144
+ else
145
+ [ key, nil ]
146
+ end
147
+ end
148
+
149
+ def fetch_by_index(ary, index)
150
+ case index
151
+ when Array
152
+ ary.values_at(*index).compact
153
+ when Integer
154
+ v = ary.at(index)
155
+ v.nil? ? [] : [v]
156
+ when Range
157
+ ary[index]
158
+ else
159
+ fail ArgumentError, "weird index: #{index.inspect}"
160
+ end
161
+ end
162
+
163
+ def normal_key?(k)
164
+ [Integer, Range].any?{|cls| k.is_a?(cls) }
165
+ end
95
166
 
96
167
  # Returns all keys that match if method == :select, the first if
97
168
  # method == :find
98
169
  def all(key, method = :select)
99
- return super if key.is_a?(Integer)
100
- return nil if key.nil?
170
+ return [self[key]] if normal_key?(key)
171
+ return [] if key.nil?
101
172
 
173
+ key, index = extract_index(key)
102
174
  predicate = expand_predicate(key)
175
+ effective_method = index.nil? ? method : :select
103
176
 
104
- send(method) do |v|
177
+ result = send(effective_method) do |v|
105
178
  predicate === v.named_array_identifier
106
179
  end
180
+
181
+ result = fetch_by_index(result, index) if index
182
+ Array(result).to_named_array
107
183
  end
184
+ indexable_wrapper :all
108
185
 
109
186
 
110
187
  # first matching item
111
188
  def [](key)
112
- return super if key.is_a?(Integer)
113
- all(key, :find)
189
+ return super if normal_key?(key)
190
+ all(key).first
114
191
  end
115
192
 
193
+ def first(key = nil)
194
+ return super() if key.nil?
195
+ all(key, :find).first
196
+ end
116
197
 
117
198
  # first matching item, or raises KeyError
118
199
  def fetch(key)
119
- all(key, :find) or raise KeyError
200
+ first(key) or raise KeyError
120
201
  end
121
202
 
122
203
 
123
204
  def key?(key)
124
- !! all(key, :find)
205
+ !! first(key)
125
206
  end
126
207
  alias_method :exist?, :key?
127
208
 
@@ -157,17 +238,24 @@ module Cult
157
238
  def with(**kw)
158
239
  fail ArgumentError, "with requires exactly one predicate" if kw.size != 1
159
240
 
160
- method, predicate = kw.first
241
+ key, predicate = kw.first
161
242
  predicate = expand_predicate(predicate)
162
243
 
163
244
  select do |candidate|
164
- method = ["names_for_#{method}", method].find do |m|
245
+ methods = [key, "query_for_#{key}", "names_for_#{key}"].select do |m|
165
246
  candidate.respond_to?(m)
166
247
  end
167
248
 
168
- if method
169
- result = Array(candidate.send(method))
170
- result.any? {|r| predicate === r }
249
+ methods.any? do |method|
250
+ Array(candidate.send(method)).any? do |r|
251
+ begin
252
+ predicate === r
253
+ rescue
254
+ # We're going to assume this is a result of a string
255
+ # comparison to a custom #==
256
+ false
257
+ end
258
+ end
171
259
  end
172
260
  end
173
261
  end
data/lib/cult/node.rb CHANGED
@@ -1,17 +1,40 @@
1
1
  require 'fileutils'
2
2
  require 'shellwords'
3
+ require 'json'
3
4
 
4
5
  require 'cult/role'
5
6
 
6
7
  module Cult
7
8
  class Node < Role
9
+ class << self
10
+ attr_accessor :marshal_exclude
11
+ end
12
+
13
+ self.marshal_exclude = [:@project]
14
+
15
+ def marshal_dump
16
+ instance_variables.reject do |key|
17
+ self.class.marshal_exclude.include?(key)
18
+ end.map do |key|
19
+ [key, instance_variable_get(key)]
20
+ end.to_h
21
+ end
22
+
23
+ def marshal_load(vars)
24
+ vars.each do |key, value|
25
+ unless self.class.marshal_exclude.include?(key)
26
+ instance_variable_set(key, value)
27
+ end
28
+ end
29
+ self.project = Cult.project
30
+ end
31
+
8
32
  def self.from_data!(project, data)
9
33
  node = by_name(project, data[:name])
10
34
  raise Errno::EEXIST if node.exist?
11
35
 
12
36
  FileUtils.mkdir_p(node.path)
13
- File.write(project.dump_name(node.node_path),
14
- project.dump_object(data))
37
+ File.write(node.node_path, JSON.pretty_generate(data))
15
38
 
16
39
  node.generate_ssh_keys!
17
40
 
@@ -19,10 +42,15 @@ module Cult
19
42
  end
20
43
 
21
44
  delegate_to_definition :host
45
+ delegate_to_definition :zone
46
+ delegate_to_definition :size
47
+ delegate_to_definition :image
48
+ delegate_to_definition :provider_name, :provider
22
49
  delegate_to_definition :ipv4_public
23
50
  delegate_to_definition :ipv4_private
24
51
  delegate_to_definition :ipv6_public
25
52
  delegate_to_definition :ipv6_private
53
+ delegate_to_definition :created_at
26
54
 
27
55
 
28
56
  def self.path(project)
@@ -31,17 +59,21 @@ module Cult
31
59
 
32
60
 
33
61
  def node_path
34
- File.join(path, 'node')
62
+ File.join(path, 'node.json')
63
+ end
64
+
65
+ def exist?
66
+ File.exist?(state_path)
35
67
  end
36
68
 
37
69
 
38
70
  def state_path
39
- File.join(path, 'state')
71
+ File.join(path, 'state.json')
40
72
  end
41
73
 
42
74
 
43
75
  def definition_path
44
- [ node_path, state_path ]
76
+ [ extra_path, state_path, node_path ]
45
77
  end
46
78
 
47
79
 
@@ -50,8 +82,8 @@ module Cult
50
82
  end
51
83
 
52
84
 
53
- def extra_file
54
- File.join(path, 'extra')
85
+ def extra_path
86
+ File.join(path, 'extra.json')
55
87
  end
56
88
 
57
89
 
@@ -59,12 +91,9 @@ module Cult
59
91
  definition.direct('roles') || super
60
92
  end
61
93
 
62
- def provider
63
- project.providers[definition['provider']]
64
- end
65
94
 
66
- def names_for_provider
67
- [ provider&.name ]
95
+ def provider
96
+ project.providers[provider_name]
68
97
  end
69
98
 
70
99
  alias_method :roles, :parent_roles
@@ -74,10 +103,21 @@ module Cult
74
103
  File.join(path, 'ssh.pub')
75
104
  end
76
105
 
106
+
77
107
  def ssh_private_key_file
78
108
  File.join(path, 'ssh.key')
79
109
  end
80
110
 
111
+ def ssh_known_hosts_file
112
+ File.join(path, 'ssh.known-host')
113
+ end
114
+
115
+ def ssh_port
116
+ # Moving SSH ports for security is lame.
117
+ definition['ssh_port'] || 22
118
+ end
119
+
120
+
81
121
  def generate_ssh_keys!
82
122
  esc = ->(s) { Shellwords.escape(s) }
83
123
  tmp_public = ssh_private_key_file + '.pub'
@@ -88,10 +128,97 @@ module Cult
88
128
  "-f #{esc.(ssh_private_key_file)} && " +
89
129
  "mv #{esc.(tmp_public)} #{esc.(ssh_public_key_file)}"
90
130
  %x(#{cmd})
131
+
91
132
  unless $?.success?
92
133
  fail "Couldn't generate SSH key, command: #{cmd}"
93
134
  end
135
+
136
+ File.chmod(0600, ssh_private_key_file)
137
+ end
138
+
139
+
140
+ def addr(access, protocol = project.default_ip_protocol)
141
+ fail ArgumentError unless [:public, :private].include?(access)
142
+ fail ArgumentError unless [:ipv4, :ipv6].include?(protocol)
143
+ send("#{protocol}_#{access}")
144
+ end
145
+
146
+
147
+ def same_network?(other)
148
+ [provider, zone] == [other.provider, other.zone]
149
+ end
150
+
151
+
152
+ def addr_from(other, protocol = project.default_ip_protocol)
153
+ same_network?(other) ? addr(:private, protocol) : addr(:public, protocol)
154
+ end
155
+
156
+
157
+ def cluster(*preds)
158
+ unless (preds - [:provider, :zone]).empty?
159
+ fail ArgumentError, "invalid predicate: #{preds.inspect}"
160
+ end
161
+
162
+ preds.push(:provider) if preds.include?(:zone)
163
+
164
+ col = project.nodes
165
+ preds.each do |pred|
166
+ col = col.select do |v|
167
+ v.send(pred) == send(pred)
168
+ end
169
+ end
170
+ col
171
+ end
172
+
173
+
174
+ def peers(*preds)
175
+ cluster(*preds).reject{|v| v == self}
176
+ end
177
+
178
+
179
+ def provider_peers
180
+ peers(:provider)
181
+ end
182
+
183
+
184
+ def leader(role, *preds)
185
+ c = cluster(*preds)
186
+ c = c.with(role: role) if role
187
+ c = c.sort_by(&:created_at)
188
+ c.first
189
+ end
190
+
191
+
192
+ def provider_leader(role = nil)
193
+ leader(role, :provider)
94
194
  end
95
195
 
196
+
197
+ def provider_leader?(role = nil)
198
+ provider_leader(role) == self
199
+ end
200
+
201
+
202
+ def zone_leader(role = nil)
203
+ leader(role, :zone)
204
+ end
205
+
206
+
207
+ def zone_leader?(role = nil)
208
+ zone_leader(role) == self
209
+ end
210
+
211
+
212
+ def zone_peers
213
+ peers(:zone)
214
+ end
215
+
216
+ def names_for_provider
217
+ [ provider_name ]
218
+ end
219
+
220
+ def names_for_zone
221
+ [zone]
222
+ end
96
223
  end
97
224
  end
@@ -0,0 +1,209 @@
1
+ # Cult.paramap runs a block in forked-off parallel processes. There are very
2
+ # little restrictions on what can be done in the block, but:
3
+ #
4
+ # 1. The value returned or any exceptions raised need to be Marshal-able.
5
+ # 2. The blocks actually need to resume execution, so things like `exec`
6
+ # cause problems.
7
+ #
8
+ # The result of paramap is an array corresponding with each value ran through
9
+ # the block. There are a few exception strategies:
10
+ #
11
+ # 1. :raise The first job to throw an exception halts all further work and the
12
+ # exception is passed up
13
+ # 2. :collect All jobs are allowed to complete, any exceptions encounted are
14
+ # tagged on the results in an `exception` method, which returns an array
15
+ # each element will either be 'nil' for no exception, or the Exception
16
+ # object the job raised.
17
+
18
+ module Cult
19
+ class Paramap
20
+ class Job
21
+ attr_reader :ident, :value, :block
22
+ attr_reader :pid, :pipe
23
+
24
+ def initialize(ident, value, block)
25
+ @ident, @value, @block = ident, value, block
26
+
27
+ @pipe = IO.pipe
28
+ @pid = fork do
29
+ @pipe[0].close
30
+ prepare_forked_environment!
31
+ begin
32
+ write_response!('=', block.call(value))
33
+ rescue Exception => e
34
+ write_response!('!', e)
35
+ end
36
+ end
37
+ @pipe[1].close
38
+ end
39
+
40
+ def prepare_forked_environment!
41
+ # Stub out things that have caused a problem in the past.
42
+ Kernel.send(:define_method, :exec) do |*a|
43
+ fail "don't use Kernel\#exec inside of a paramap job"
44
+ end
45
+ end
46
+
47
+ def write_response!(scode, obj)
48
+ fail unless ['!', '='].include?(scode)
49
+ begin
50
+ pipe[1].write(scode + Marshal.dump(obj))
51
+ rescue TypeError => e
52
+ # Unmarshallable
53
+ raise unless e.message.match(/_dump_data/)
54
+ pipe[1].write(scode + Marshal.dump(nil))
55
+ end
56
+ pipe[1].flush
57
+ pipe[1].close
58
+ end
59
+
60
+ def fetch_response!
61
+ unless pipe[0].closed?
62
+ data = @pipe[0].read
63
+
64
+ scode = data[0]
65
+ fail unless ['!', '='].include?(scode)
66
+
67
+ data = data[1..-1]
68
+ ivar = (scode == '!') ? :exception : :result
69
+ begin
70
+ obj = Marshal.load(data)
71
+ rescue
72
+ obj = nil
73
+ end
74
+ instance_variable_set("@#{ivar}", obj)
75
+ pipe[0].close
76
+ end
77
+ end
78
+
79
+ def result
80
+ fetch_response!
81
+ @result
82
+ end
83
+
84
+ def exception
85
+ fetch_response!
86
+ @exception
87
+ end
88
+
89
+ def success?
90
+ exception.nil?
91
+ end
92
+ end
93
+
94
+ attr_reader :enum, :iter
95
+ attr_reader :block
96
+ attr_reader :job_queue
97
+ attr_reader :exception_strategy
98
+ attr_reader :exceptions
99
+ attr_reader :results
100
+ attr_reader :concurrent
101
+
102
+ def initialize(enum, concurrent: nil, exception_strategy:, &block)
103
+ @enum = enum
104
+ @iter = @enum.to_enum
105
+ @concurrent = concurrent || max_concurrent
106
+ @exception_strategy = exception_strategy
107
+ @block = block
108
+ @exceptions, @results = [], []
109
+ @job_queue = []
110
+ end
111
+
112
+ def max_concurrent
113
+ case (r = Cult.concurrency)
114
+ when :max
115
+ enum.respond_to?(:size) ? enum.size : 200
116
+ else
117
+ r
118
+ end
119
+ end
120
+
121
+ def handle_exception(job)
122
+ case exception_strategy
123
+ when :raise
124
+ raise job.exception
125
+ when :collect
126
+ exceptions.push(job.exception)
127
+ else
128
+ fail "Bad exception_strategy: #{exception_strategy}"
129
+ end
130
+ end
131
+
132
+ def handle_result(job)
133
+ results[job.ident] = job.result
134
+ end
135
+
136
+ def handle_response(job)
137
+ job.success? ? handle_result(job) : handle_exception(job)
138
+ end
139
+
140
+ def new_job_index
141
+ (@job_index ||= 0).tap do
142
+ @job_index += 1
143
+ end
144
+ end
145
+
146
+ def add_job(value)
147
+ job_queue.push(Job.new(new_job_index, value, block))
148
+ end
149
+
150
+ def job_by_pid(pid)
151
+ job_queue.find { |job| job.pid == pid }
152
+ end
153
+
154
+ def process_finished_job(job)
155
+ job_queue.delete(job)
156
+ handle_response(job)
157
+ end
158
+
159
+ def report_exceptions(results)
160
+ self_exceptions = self.exceptions
161
+ results.define_singleton_method(:exceptions) do
162
+ self_exceptions
163
+ end
164
+ end
165
+
166
+ def job_queue_full?
167
+ job_queue.size == concurrent
168
+ end
169
+
170
+ def more_tasks?
171
+ iter.peek
172
+ true
173
+ rescue StopIteration
174
+ false
175
+ end
176
+
177
+ def next_task
178
+ iter.next
179
+ end
180
+
181
+ def queue_next_task
182
+ add_job(next_task)
183
+ end
184
+
185
+ def wait_for_next_job_to_finish
186
+ if (job = job_by_pid(Process.waitpid))
187
+ process_finished_job(job)
188
+ end
189
+ end
190
+
191
+ def run
192
+ loop do
193
+ queue_next_task until job_queue_full? || !more_tasks?
194
+ break if job_queue.empty? && ! more_tasks?
195
+ wait_for_next_job_to_finish
196
+ end
197
+
198
+ report_exceptions(self.results)
199
+ self.results
200
+ end
201
+ end
202
+ private_constant :Paramap
203
+
204
+ module_function
205
+ def paramap(enum, concurrent: nil, exception: :raise, &block)
206
+ Paramap.new(enum, concurrent: concurrent,
207
+ exception_strategy: exception, &block).run
208
+ end
209
+ end
data/lib/cult/project.rb CHANGED
@@ -1,8 +1,6 @@
1
1
  require 'securerandom'
2
2
  require 'shellwords'
3
3
  require 'json'
4
- require 'yaml'
5
-
6
4
 
7
5
  module Cult
8
6
  class Project
@@ -10,8 +8,10 @@ module Cult
10
8
 
11
9
  attr_reader :path
12
10
  attr_accessor :cult_version
11
+ attr_accessor :default_ip_protocol
13
12
 
14
13
  def initialize(path)
14
+ @default_ip_protocol = :ipv4
15
15
  @path = path
16
16
  end
17
17
 
@@ -127,21 +127,6 @@ module Cult
127
127
  end
128
128
 
129
129
 
130
- def dump_yaml?
131
- !! (ENV['CULT_DUMP'] || '').match(/^yaml$/i)
132
- end
133
-
134
-
135
- def dump_object(obj)
136
- dump_yaml? ? YAML.dump(obj) : JSON.pretty_generate(obj)
137
- end
138
-
139
-
140
- def dump_name(basename)
141
- basename + (dump_yaml? ? '.yml' : '.json')
142
- end
143
-
144
-
145
130
  def env
146
131
  ENV['CULT_ENV'] || begin
147
132
  if git_branch&.match(/\bdev(el(opment)?)?\b/)
data/lib/cult/provider.rb CHANGED
@@ -43,7 +43,9 @@ module Cult
43
43
 
44
44
 
45
45
  def definition_path
46
- [File.join(path, "provider"), File.join(path, "defaults")]
46
+ [ File.join(path, "extra.json"),
47
+ File.join(path, "defaults.json"),
48
+ File.join(path, "provider.json") ]
47
49
  end
48
50
 
49
51