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
@@ -6,7 +6,7 @@ module Cult
6
6
  optional_project
7
7
  name 'task'
8
8
  aliases 'tasks'
9
- summary 'Task Manipulation'
9
+ summary 'Task manipulation'
10
10
  usage 'task [command]'
11
11
  description <<~EOD.format_description
12
12
  Tasks are basically shell scripts. Or anything with a \#! line, or
@@ -31,8 +31,9 @@ module Cult
31
31
  neatly line these up for you.
32
32
  EOD
33
33
 
34
- run(arguments: 0) do |opts, args, cmd|
34
+ run(arguments: none) do |opts, args, cmd|
35
35
  puts cmd.help
36
+ exit
36
37
  end
37
38
  end
38
39
 
@@ -42,10 +43,10 @@ module Cult
42
43
  aliases 'reserial'
43
44
  summary 'Resequences task serial numbers'
44
45
 
45
- flag :A, :all, 'Re-sequence all roles'
46
- flag :G, :'git-add', '`git add` each change'
47
- required :r, :role, 'Roles to resequence (multiple)',
48
- multiple: true
46
+ flag :A, :all, 'Re-sequence all roles'
47
+ flag :G, :'git-add', '`git add` each change'
48
+ required :r, :role, 'Resequence only /NODE+/ (multiple)',
49
+ multiple: true
49
50
 
50
51
  description <<~EOD.format_description
51
52
  Resequences the serial numbers in each task provided with --roles,
@@ -65,7 +66,7 @@ module Cult
65
66
  EOD
66
67
 
67
68
 
68
- run(arguments: 0) do |opts, args, cmd|
69
+ run(arguments: none) do |opts, args, cmd|
69
70
  if opts[:all] && Array(opts[:role]).size != 0
70
71
  fail CLIError, "can't supply -A and also a list of roles"
71
72
  end
@@ -131,24 +132,23 @@ module Cult
131
132
  task.add_command task_sanity
132
133
 
133
134
 
134
- task_create = Cri::Command.define do
135
- name 'create'
136
- aliases 'new'
135
+ task_new = Cri::Command.define do
136
+ name 'new'
137
137
  usage 'create [options] DESCRIPTION'
138
138
  summary 'create a new task for ROLE with a proper serial'
139
139
  description <<~EOD.format_description
140
140
  EOD
141
141
 
142
- required :r, :role, 'role for task. defaults to "all"'
142
+ required :r, :role, '/ROLE/ for task. defaults to "base"'
143
143
  flag :e, :edit, 'open generated task file in your $EDITOR'
144
144
 
145
145
  run do |opts, args, cmd|
146
146
  english = args.join " "
147
- opts[:roles] ||= 'all'
147
+ opts[:roles] ||= 'base'
148
148
  puts [english, opts[:roles], opts[:edit]].inspect
149
149
  end
150
150
  end
151
- task.add_command task_create
151
+ task.add_command(task_new)
152
152
 
153
153
  task
154
154
  end
@@ -18,6 +18,7 @@ module Cult
18
18
  Shellwords.escape(s)
19
19
  end
20
20
 
21
+
21
22
  def send_tar(io, ssh)
22
23
  filename = SecureRandom.hex + ".tar"
23
24
  puts "Uploading bundle: #{filename}"
@@ -26,6 +27,7 @@ module Cult
26
27
  ssh.exec! "tar -xf #{esc(filename)} && rm #{esc(filename)}"
27
28
  end
28
29
 
30
+
29
31
  def create_build_tar(role)
30
32
  io = StringIO.new
31
33
  Bundle.new(io) do |bundle|
@@ -41,11 +43,12 @@ module Cult
41
43
  io
42
44
  end
43
45
 
46
+
44
47
  def exec_remote!(ssh:, role:, task:)
45
48
  token = SecureRandom.hex
46
49
  task_bin = role.relative_path(task.path)
47
50
 
48
- puts "Executing: #{task.remote_path}"
51
+ puts "Executing: #{task.remote_path} on #{node.name}"
49
52
  res = ssh.exec! <<~BASH
50
53
  cd #{esc(role.remote_path)}; \
51
54
  ./#{esc(task_bin)} && \
@@ -63,6 +66,7 @@ module Cult
63
66
  end
64
67
  end
65
68
 
69
+
66
70
  def install!(role)
67
71
  connect(user: role.user) do |ssh|
68
72
  io = create_build_tar(role)
@@ -77,20 +81,28 @@ module Cult
77
81
  end
78
82
  end
79
83
 
80
- def find_sync_tasks
84
+
85
+ def find_sync_tasks(pass:, roles: nil)
86
+ selected_roles = node.build_order
87
+
88
+ if roles
89
+ selected_roles.select! { |r| roles.include?(r) }
90
+ end
91
+
81
92
  r = []
82
- node.build_order.each do |role|
83
- role.event_tasks.each do |task|
84
- r << task if task.name == 'sync'
93
+ selected_roles.each do |role|
94
+ r += role.event_tasks.select do |t|
95
+ t.event == :sync && t.pass == pass
85
96
  end
86
97
  end
87
98
  r
88
99
  end
89
100
 
90
- def create_sync_tar
101
+
102
+ def create_sync_tar(pass:, roles: nil)
91
103
  io = StringIO.new
92
104
  Bundle.new(io) do |bundle|
93
- find_sync_tasks.each do |task|
105
+ find_sync_tasks(pass: pass, roles: roles).each do |task|
94
106
  bundle.add_file(project, task.role, node, task)
95
107
  end
96
108
  end
@@ -99,29 +111,53 @@ module Cult
99
111
  io
100
112
  end
101
113
 
102
- def sync!
114
+
115
+ def sync!(pass:, roles: nil)
116
+ io = create_sync_tar(pass: pass, roles: roles)
117
+ return if io.eof?
118
+
103
119
  connect do |ssh|
104
- io = create_sync_tar
105
120
  send_tar(io, ssh)
106
- find_sync_tasks.each do |task|
121
+ find_sync_tasks(pass: pass, roles: roles).each do |task|
107
122
  exec_remote!(ssh: ssh, role: task.role, task: task)
108
123
  end
109
124
  end
110
125
  end
111
126
 
127
+
112
128
  def bootstrap!
113
129
  bootstrap_role = CLI.fetch_item('bootstrap', from: Role)
114
130
  install!(bootstrap_role)
115
131
  end
116
132
 
133
+ def ping
134
+ connect do |ssh|
135
+ ssh.exec! "uptime"
136
+ end
137
+ rescue
138
+ nil
139
+ end
140
+
117
141
  def connect(user: nil, &block)
118
- user ||= node.user
119
- puts "Connecting with user=#{user}, key=#{node.ssh_private_key_file}"
120
- Net::SSH.start(node.host,
121
- user,
122
- keys_only: true,
123
- keys: [node.ssh_private_key_file]) do |ssh|
124
- yield ssh
142
+ 5.times do |attempt|
143
+ begin
144
+ user ||= node.user
145
+ puts "Connecting with user=#{user}, host=#{node.host}, " +
146
+ "key=#{node.ssh_private_key_file}"
147
+ Net::SSH.start(node.host,
148
+ user,
149
+ port: node.ssh_port,
150
+ user_known_hosts_file: node.ssh_known_hosts_file,
151
+ timeout: 5,
152
+ auth_methods: ['publickey'],
153
+ keys_only: true,
154
+ keys: [node.ssh_private_key_file]) do |ssh|
155
+ return (yield ssh)
156
+ end
157
+ rescue Errno::ECONNREFUSED, Net::SSH::ConnectionTimeout
158
+ puts "Connection refused. Retrying"
159
+ sleep attempt * 3
160
+ end
125
161
  end
126
162
  end
127
163
  end
@@ -0,0 +1,29 @@
1
+ module Cult
2
+ class CommanderSync
3
+ attr_reader :project, :nodes
4
+ def initialize(project:, nodes:)
5
+ @project, @nodes = project, nodes
6
+ end
7
+
8
+ def sync!(roles: nil, passes: nil)
9
+ roles ||= Cult.project.roles
10
+ passes ||= required_passes(roles)
11
+
12
+ passes.each do |pass|
13
+ puts Rainbow("Executing pass #{pass}").yellow
14
+ Cult.paramap(nodes) do |node|
15
+ c = Commander.new(project: project, node: node)
16
+ c.sync!(pass: pass, roles: roles)
17
+ end
18
+ end
19
+ end
20
+
21
+ def required_passes(roles)
22
+ # searches through every node and extracts which passes have to be ran
23
+ # to satisfy every event task
24
+ nodes.map(&:build_order).flatten.uniq
25
+ .select { |r| roles.nil? ? true : roles.include?(r) }
26
+ .map(&:event_tasks).flatten.map(&:pass).uniq.sort
27
+ end
28
+ end
29
+ end
@@ -1,83 +1,55 @@
1
- require 'yaml'
2
1
  require 'json'
3
- require 'forwardable'
4
2
 
5
3
  module Cult
6
4
  class Definition
7
5
  attr_reader :object
8
6
  attr_reader :bag
9
7
 
10
- extend Forwardable
11
- def_delegators :object, :definition_parameters,
12
- :definition_path,
13
- :definition_parents
14
-
15
8
  def initialize(object)
16
9
  @object = object
17
10
  end
18
11
 
12
+ def definition_parameters
13
+ object.definition_parameters
14
+ end
15
+
16
+ def definition_path
17
+ object.definition_path
18
+ end
19
+
20
+ def definition_parents
21
+ object.definition_parents
22
+ end
23
+
19
24
  def inspect
20
25
  "\#<#{self.class.name} " +
21
26
  "object: #{object.inspect}, " +
22
27
  "params: #{definition_parameters}, " +
23
28
  "parents: #{definition_parents}, " +
24
- "direct_values: #{bag}>"
29
+ "bag: #{bag}>"
25
30
  end
26
31
  alias_method :to_s, :inspect
27
32
 
28
-
29
- def filenames
30
- Array(definition_path).map do |dp|
31
- attempt = [ "#{dp}.yaml", "#{dp}.yml", "#{dp}.json" ]
32
- existing = attempt.select do |filename|
33
- File.exist?(filename)
34
- end
35
- if existing.size > 1
36
- raise RuntimeError, "conflicting definition files: #{existing}"
37
- end
38
- existing[0]
39
- end.compact
40
- end
41
-
42
-
43
- def decoder_for(filename)
44
- @decoder_for ||= begin
45
- case filename
46
- when nil
47
- nil
48
- when /\.json\z/
49
- JSON.method(:parse)
50
- when /\.ya?ml\z/
51
- YAML.method(:safe_load)
52
- else
53
- fail RuntimeError, "No decoder for file type: #{filename}"
54
- end
55
- end
56
- end
57
-
58
-
59
33
  def bag
60
- @bag ||= begin
61
- result = {}
62
- filenames.each do |filename|
63
- erb = ::Cult::Template.new(project: nil, **definition_parameters)
64
- contents = erb.process(File.read(filename), filename: filename)
65
- result.merge! decoder_for(filename).call(contents)
66
- end
67
- result
34
+ @bag ||= Array(definition_path).select do |filename|
35
+ File.exist?(filename)
36
+ end.inject({}) do |acc, filename|
37
+ erb = ::Cult::Template.new(project: nil, **definition_parameters)
38
+ contents = erb.process(File.read(filename), filename: filename)
39
+ JSON.parse(contents).merge(acc)
68
40
  end
69
41
  end
70
42
  alias_method :to_h, :bag
71
43
 
72
44
 
73
45
  def direct(k)
74
- fail "Use string keys" unless k.is_a?(String)
46
+ fail ArgumentError unless k.is_a?(String)
75
47
  bag[k]
76
48
  end
77
49
 
78
50
 
79
51
  def [](k)
80
- fail "Use string keys" unless k.is_a?(String)
52
+ fail ArgumentError unless k.is_a?(String)
81
53
  if bag.key?(k)
82
54
  bag[k]
83
55
  else
data/lib/cult/driver.rb CHANGED
@@ -80,7 +80,7 @@ module Cult
80
80
  end
81
81
 
82
82
 
83
- def self.new(api_key:)
83
+ def self.new(*args)
84
84
  try_requires!
85
85
  super
86
86
  end
@@ -90,7 +90,8 @@ module Cult
90
90
 
91
91
  # We don't particularly need the debian codename
92
92
  s = s.gsub(/(\d)[\s-]+(\S+)/, '\1') if s.match(/^debian/i)
93
- s
93
+ s = s.gsub(/[\s.]+/, '-')
94
+ s.downcase
94
95
  end
95
96
 
96
97
 
@@ -100,14 +101,12 @@ module Cult
100
101
  times = 0
101
102
  total_wait = 0.0
102
103
 
103
- catch :done do
104
- loop do
105
- yield times, total_wait
106
- sleep wait
107
- times += 1
108
- total_wait += wait
109
- wait *= scale
110
- end
104
+ loop do
105
+ yield times, total_wait
106
+ sleep wait
107
+ times += 1
108
+ total_wait += wait
109
+ wait *= scale
111
110
  end
112
111
  end
113
112
 
@@ -115,11 +114,13 @@ module Cult
115
114
  # Waits until SSH is available at host. "available" jsut means
116
115
  # "listening"/acceping connections.
117
116
  def await_ssh(host)
117
+ puts "Awaiting sshd on #{host}"
118
118
  backoff_loop do
119
119
  begin
120
120
  sock = connect_timeout(host, 22, 1)
121
- throw :done
122
- rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED
121
+ break
122
+ rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
123
+ Errno::EHOSTDOWN
123
124
  # Nothing, these are expected
124
125
  ensure
125
126
  sock.close if sock
@@ -58,7 +58,7 @@ module Cult
58
58
  d = nil
59
59
  backoff_loop do
60
60
  d = client.droplets.find(id: droplet.id)
61
- throw :done if d.status == 'active'
61
+ break if d.status == 'active'
62
62
  end
63
63
  return d
64
64
  end
@@ -77,7 +77,7 @@ module Cult
77
77
  transaction do |xac|
78
78
  ssh_key_id = upload_ssh_key(file: ssh_public_key)
79
79
  xac.rollback do
80
- destroy_ssh_key!(id: ssh_key_id)
80
+ destroy_ssh_key!(ssh_key_id: ssh_key_id)
81
81
  end
82
82
 
83
83
  begin
@@ -0,0 +1,156 @@
1
+ require 'shellwords'
2
+
3
+ module Cult
4
+ module Drivers
5
+ class VirtualBoxDriver < ::Cult::Driver
6
+
7
+ def initialize(api_key:)
8
+ end
9
+
10
+
11
+ def sizes_map
12
+ [1, 2, 4, 6, 8, 10, 12, 16].map do |size|
13
+ ["#{size}gb", size * 1024 * 1024]
14
+ end.to_h
15
+ end
16
+ with_id_mapping :sizes_map
17
+
18
+
19
+ def images_map
20
+ %x(VBoxManage list vms).each_line.map do |line|
21
+ words = Shellwords.split(line.chomp)
22
+ [ distro_name(words[0]), words[1] ]
23
+ end.to_h
24
+ end
25
+ with_id_mapping :images_map
26
+
27
+
28
+ def zones
29
+ ['local']
30
+ end
31
+
32
+
33
+ def ip_property_name(index, protocol)
34
+ protocols = {
35
+ ipv4: 'V4',
36
+ ipv6: 'V6'
37
+ }
38
+ "/VirtualBox/GuestInfo/Net/#{index}/#{protocols[protocol]}/IP"
39
+ end
40
+
41
+
42
+ def esc(s)
43
+ Shellwords.escape(s)
44
+ end
45
+
46
+
47
+ def unset_ip_data(name, index, protocol)
48
+ cmd = "VBoxManage guestproperty unset #{esc(name)} " +
49
+ "#{ip_property_name(index, protocol)}"
50
+ `#{cmd}`
51
+ end
52
+
53
+
54
+ def get_ip_data(name, index, protocol)
55
+ cmd = "VBoxManage guestproperty get #{esc(name)} " +
56
+ "#{ip_property_name(index, protocol)}"
57
+ s = `#{cmd}`
58
+
59
+ if $?.success? && (m = s.match(/^Value: (.+)$/))
60
+ m[1]
61
+ else
62
+ nil
63
+ end
64
+ end
65
+
66
+
67
+ def await_ip_address(name, index, protocol)
68
+ puts "Awaiting IP address from VirtualBox Guest Additions"
69
+ unset_ip_data(name, index, protocol)
70
+
71
+ backoff_loop do
72
+ if (ip = get_ip_data(name, index, protocol))
73
+ return ip
74
+ end
75
+ end
76
+ end
77
+
78
+
79
+ def destroy!(id:, ssh_key_id:)
80
+ system 'VBoxManage', 'controlvm', id, 'poweroff'
81
+ system 'VBoxManage', 'unregistervm', id, '--delete'
82
+ end
83
+
84
+
85
+ def guest_copy(name, src, dst)
86
+ cmd = "VBoxManage guestcontrol #{esc(name)} " +
87
+ "--username root --password password " +
88
+ "copyto #{esc(src)} --target-directory #{esc(dst)}"
89
+ puts cmd
90
+ `#{cmd}`
91
+ end
92
+
93
+
94
+ def guest_command(name, cmd)
95
+ cmd = "VBoxManage guestcontrol #{esc(name)} " +
96
+ "--username root --password password " +
97
+ "run -- /bin/sh -c #{esc(cmd)}"
98
+ puts cmd
99
+ `#{cmd}`
100
+ end
101
+
102
+
103
+ def provision!(name:, size:, zone:, image:, ssh_public_key:)
104
+ system 'VBoxManage', 'clonevm',
105
+ fetch_mapped(name: :image, from: images_map, key: image),
106
+ '--name', name, '--register'
107
+
108
+ system 'VBoxManage', 'modifyvm', name, '--groups', '/Cult'
109
+ system 'VBoxManage', 'startvm', name, '--type', 'headless'
110
+
111
+ public_ip = await_ip_address(name, 0, :ipv4)
112
+ private_ip = public_ip
113
+
114
+ await_ssh(public_ip)
115
+
116
+ guest_command(name, "mkdir -m 0600 /root/.ssh")
117
+ guest_copy(name, ssh_public_key, "/root/.ssh/authorized_keys")
118
+ guest_command(name, "chmod 0644 /root/.ssh/authorized_keys")
119
+ guest_command(name, "passwd -l root")
120
+
121
+ return {
122
+ name: name,
123
+ size: size,
124
+ zone: zone,
125
+ image: image,
126
+
127
+ id: name,
128
+ created_at: Time.now.iso8601,
129
+ host: public_ip,
130
+ ipv4_public: public_ip,
131
+ ipv4_private: private_ip,
132
+ ipv6_public: nil,
133
+ ipv6_private: nil,
134
+ meta: {}
135
+ }
136
+ end
137
+
138
+ def self.setup!
139
+ super
140
+
141
+ inst = new(api_key: nil)
142
+
143
+ return {
144
+ driver: driver_name,
145
+ api_key: nil,
146
+ configurations: {
147
+ sizes: inst.sizes,
148
+ zones: inst.zones,
149
+ images: inst.images,
150
+ }
151
+ }
152
+ end
153
+
154
+ end
155
+ end
156
+ end
@@ -31,7 +31,7 @@ module Cult
31
31
 
32
32
 
33
33
  def zones_map
34
- Vultr::Region.list[:result].map do |k, v|
34
+ Vultr::Regions.list[:result].map do |k, v|
35
35
  [slugify(v["regioncode"]), v["DCID"]]
36
36
  end.to_h
37
37
  end
@@ -57,7 +57,7 @@ module Cult
57
57
 
58
58
 
59
59
  def sizes_map
60
- Vultr::Plan.list[:result].values.select do |v|
60
+ Vultr::Plans.list[:result].values.select do |v|
61
61
  v["plan_type"] == 'SSD'
62
62
  end.map do |v|
63
63
  if (m = v["name"].match(/^(\d+) ([MGTP]B) RAM/i))
@@ -141,7 +141,7 @@ module Cult
141
141
  # Wait until it's active, it won't have an IP until then
142
142
  backoff_loop do
143
143
  r = Vultr::Server.list(SUBID: subid)[:result]
144
- throw :done if r['status'] == 'active'
144
+ break if r['status'] == 'active'
145
145
  end
146
146
 
147
147
  iplist4 = Vultr::Server.list_ipv4(SUBID: subid)[:result].values[0]