cult 0.1.1.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +240 -0
  6. data/Rakefile +6 -0
  7. data/cult +1 -0
  8. data/cult.gemspec +38 -0
  9. data/doc/welcome.txt +1 -0
  10. data/exe/cult +86 -0
  11. data/lib/cult/artifact.rb +45 -0
  12. data/lib/cult/cli/common.rb +265 -0
  13. data/lib/cult/cli/console_cmd.rb +124 -0
  14. data/lib/cult/cli/cri_extensions.rb +84 -0
  15. data/lib/cult/cli/init_cmd.rb +116 -0
  16. data/lib/cult/cli/load.rb +26 -0
  17. data/lib/cult/cli/node_cmd.rb +205 -0
  18. data/lib/cult/cli/provider_cmd.rb +123 -0
  19. data/lib/cult/cli/role_cmd.rb +149 -0
  20. data/lib/cult/cli/task_cmd.rb +140 -0
  21. data/lib/cult/commander.rb +103 -0
  22. data/lib/cult/config.rb +22 -0
  23. data/lib/cult/definition.rb +112 -0
  24. data/lib/cult/driver.rb +88 -0
  25. data/lib/cult/drivers/common.rb +192 -0
  26. data/lib/cult/drivers/digital_ocean_driver.rb +179 -0
  27. data/lib/cult/drivers/linode_driver.rb +282 -0
  28. data/lib/cult/drivers/load.rb +26 -0
  29. data/lib/cult/drivers/script_driver.rb +27 -0
  30. data/lib/cult/drivers/vultr_driver.rb +217 -0
  31. data/lib/cult/named_array.rb +129 -0
  32. data/lib/cult/node.rb +62 -0
  33. data/lib/cult/project.rb +169 -0
  34. data/lib/cult/provider.rb +134 -0
  35. data/lib/cult/role.rb +213 -0
  36. data/lib/cult/skel.rb +85 -0
  37. data/lib/cult/task.rb +64 -0
  38. data/lib/cult/template.rb +92 -0
  39. data/lib/cult/transferable.rb +61 -0
  40. data/lib/cult/version.rb +3 -0
  41. data/lib/cult.rb +4 -0
  42. data/skel/.cultconsolerc +4 -0
  43. data/skel/.cultrc.erb +29 -0
  44. data/skel/README.md.erb +22 -0
  45. data/skel/keys/.keep +0 -0
  46. data/skel/nodes/.keep +0 -0
  47. data/skel/providers/.keep +0 -0
  48. data/skel/roles/all/role.json +4 -0
  49. data/skel/roles/all/tasks/00000-do-something-cool +27 -0
  50. data/skel/roles/bootstrap/files/cult-motd +45 -0
  51. data/skel/roles/bootstrap/role.json +4 -0
  52. data/skel/roles/bootstrap/tasks/00000-set-hostname +22 -0
  53. data/skel/roles/bootstrap/tasks/00001-add-cult-user +21 -0
  54. data/skel/roles/bootstrap/tasks/00002-install-cult-motd +9 -0
  55. metadata +183 -0
@@ -0,0 +1,265 @@
1
+ require 'io/console'
2
+ require 'shellwords'
3
+
4
+ module Cult
5
+ module CLI
6
+
7
+ class CLIError < RuntimeError
8
+ end
9
+
10
+ module_function
11
+
12
+ # This sets the global project based on a directory
13
+ def set_project(path)
14
+ Cult.project = Cult::Project.locate(path)
15
+ if Cult.project.nil?
16
+ fail CLIError, "#{path} does not contain a valid Cult project"
17
+ end
18
+ end
19
+
20
+
21
+ # Quiet mode controls how verbose `say` is
22
+ def quiet=(v)
23
+ @quiet = v
24
+ end
25
+
26
+
27
+ def quiet?
28
+ @quiet
29
+ end
30
+
31
+
32
+ def say(v)
33
+ puts v unless @quiet
34
+ end
35
+
36
+
37
+ # yes=true automatically answers yes to "yes_no" questions.
38
+ def yes=(v)
39
+ @yes = v
40
+ end
41
+
42
+
43
+ def yes?
44
+ @yes
45
+ end
46
+
47
+
48
+ # Asks a yes or no question with promp. The prompt defaults to "Yes". If
49
+ # Cli.yes=true, true is returned without showing the prompt.
50
+ def yes_no?(prompt, default: true)
51
+ return true if yes?
52
+
53
+ default = case default
54
+ when :y, :yes
55
+ true
56
+ when :n, :no
57
+ false
58
+ when true, false
59
+ default
60
+ else
61
+ fail ArgumentError, "invalid :default"
62
+ end
63
+
64
+ loop do
65
+ y = default ? Rainbow('Y').bright : Rainbow('y').darkgray
66
+ n = !default ? Rainbow('N').bright : Rainbow('n').darkgray
67
+
68
+ begin
69
+ print "#{prompt} #{y}/#{n}: "
70
+ case $stdin.gets.chomp
71
+ when ''
72
+ return default
73
+ when /^[Yy]/
74
+ return true
75
+ when /^[Nn]/
76
+ return false
77
+ else
78
+ $stderr.puts "Unrecognized response"
79
+ end
80
+ rescue Interrupt
81
+ puts
82
+ raise
83
+ end
84
+ end
85
+ end
86
+
87
+
88
+ # Asks the user a question, and returns the response. Ensures a newline
89
+ # exists after the response.
90
+ def ask(prompt)
91
+ print "#{prompt}: "
92
+ $stdin.gets.chomp
93
+ end
94
+
95
+
96
+ def prompt(*args)
97
+ ask(*args)
98
+ end
99
+
100
+
101
+ # Disables echo to ask the user a password.
102
+ def password(prompt)
103
+ STDIN.noecho do
104
+ begin
105
+ ask(prompt)
106
+ ensure
107
+ puts
108
+ end
109
+ end
110
+ end
111
+
112
+
113
+ # it's common for drivers to need the user to visit a URL to
114
+ # confirm an API key or similar. This does this in the most
115
+ # compatable way I know.
116
+ def launch_browser(url)
117
+ case RUBY_PLATFORM
118
+ when /darwin/
119
+ system "open", url
120
+ when /mswin|mingw|cygwin/
121
+ system "start", url
122
+ else
123
+ system "xdg-open", url
124
+ end
125
+ end
126
+
127
+
128
+ # We actually want "base 47", so we have to generate substantially more
129
+ # characters than len. The method already generates 1.25*len characters,
130
+ # but is offset by _ and - that we discard. With the other characters we
131
+ # discard, we usethe minimum multiplier which makes a retry "rare" (every
132
+ # few thousand ids at 6 len), then handle that case.
133
+ def unique_id(len = 8)
134
+ @uniq_id_disallowed ||= /[^abcdefhjkmnpqrtvwxyzABCDEFGHJKMNPQRTVWXY2346789]/
135
+ candidate = SecureRandom.urlsafe_base64((len * 2.1).ceil)
136
+ .gsub(@uniq_id_disallowed, '')
137
+ fail RangeError if candidate.size < len
138
+ candidate[0...len]
139
+ rescue RangeError
140
+ retry
141
+ end
142
+
143
+
144
+ # v is an option or argv value from a user, label: is the name of it.
145
+ #
146
+ # This asserts that `v` is in the collection `from`, and returns it.
147
+ # if `exist` is false, it verifies that v is NOT in the collection and
148
+ # returns v.
149
+ #
150
+ # As a convenience, `from` can be a class like Role, which will imply
151
+ # 'Cult.project.roles'
152
+ #
153
+ # CLIError is raised if these invariants are violated
154
+ def fetch_item(v, from:, label: nil, exist: true, method: :fetch)
155
+ implied_from = case
156
+ when from == Driver; Cult::Drivers.all
157
+ when from == Provider; Cult.project.providers
158
+ when from == Role; Cult.project.roles
159
+ when from == Node; Cult.project.nodes
160
+ else; nil
161
+ end
162
+
163
+ label ||= implied_from ? from.name.split('::')[-1].downcase : nil
164
+ from = implied_from
165
+
166
+ fail ArgumentError, "label cannot be implied" if label.nil?
167
+
168
+ unless [:fetch, :all].include?(method)
169
+ fail ArgumentError, "method must be :fetch or :all"
170
+ end
171
+
172
+ # We got no argument
173
+ fail CLIError, "Expected #{label}" if v.nil?
174
+
175
+ if exist
176
+ begin
177
+ from.send(method, v).tap do |r|
178
+ # Make sure
179
+ fail KeyError if method == :all && r.empty?
180
+ end
181
+ rescue KeyError
182
+ fail CLIError, "#{label} does not exist: #{v}"
183
+ end
184
+ else
185
+ if from.key?(v)
186
+ fail CLIError, "#{label} already exists: #{v}"
187
+ end
188
+ v
189
+ end
190
+ end
191
+
192
+
193
+ # Takes a list of keys and returns an array of objects that correspond
194
+ # to any of them. If required is true, each key must correspond to at
195
+ # least one object.
196
+ def fetch_items(*keys, **kw)
197
+ keys.flatten.map do |key|
198
+ fetch_item(key, method: :all, **kw)
199
+ end.flatten
200
+ end
201
+
202
+
203
+ # This intercepts GemNeededError and does the installation dance. It looks
204
+ # a bit hairy because it has a few resumption points, e.g., attempts user
205
+ # gem install, and if that fails, tries the sudo gem install.
206
+ def offer_gem_install(&block)
207
+ prompt_install = ->(gems) do
208
+ unless quiet?
209
+ print <<~EOD
210
+ This driver requires the installation of one or more gems:
211
+
212
+ #{gems.inspect}
213
+
214
+ Cult can install them for you.
215
+ EOD
216
+ end
217
+ yes_no?("Install?")
218
+ end
219
+
220
+ try_install = ->(gem, sudo: false) do
221
+ cmd = "gem install #{Shellwords.escape(gem)}"
222
+ cmd = "sudo #{cmd}" if sudo
223
+ puts "executing: #{cmd}"
224
+ system cmd
225
+ $?.success?
226
+ end
227
+
228
+ begin
229
+ yield
230
+ rescue ::Cult::Driver::GemNeededError => needed
231
+ sudo = false
232
+ loop do
233
+ sudo = catch :sudo_attempt do
234
+ # We don't want to show this again on a retry
235
+ raise unless sudo || prompt_install.(needed.gems)
236
+
237
+ needed.gems.each do |gem|
238
+ success = try_install.(gem, sudo: sudo)
239
+ if !success
240
+ if sudo
241
+ puts "Nothing seemed to have worked. Giving up."
242
+ puts "The gems needed are #{needed.gems.inspect}."
243
+ raise
244
+ else
245
+ puts "It doesn't look like that went well."
246
+ if yes_no?("Retry with sudo?")
247
+ throw :sudo_attempt, true
248
+ end
249
+ raise
250
+ end
251
+ end
252
+ end
253
+
254
+ # We exit our non-loop: Everything went fine.
255
+ break
256
+ end
257
+ end
258
+
259
+ # Everything went fine, we need to retry the user-supplied block.
260
+ Gem.refresh
261
+ retry
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,124 @@
1
+ require 'delegate'
2
+ require 'rainbow'
3
+
4
+ module Cult
5
+ module CLI
6
+
7
+ class ConsoleContext < SimpleDelegator
8
+ attr_accessor :original_argv
9
+
10
+
11
+ def initialize(project, argv)
12
+ super(project)
13
+
14
+ @original_argv = [$0, *argv]
15
+ ENV['CULT_PROJECT'] = self.path
16
+ end
17
+
18
+
19
+ def load_rc
20
+ consolerc = project.location_of(".cultconsolerc")
21
+
22
+ # We don't `load' so the rc file has a more convenient context.
23
+ eval File.read(consolerc) if File.exist?(consolerc)
24
+ end
25
+
26
+
27
+ private def exit(*)
28
+ # IRB tries to alias this. And it must be private, or it warns. WTF.
29
+ super
30
+ end
31
+
32
+
33
+ # Gives us an escape hatch to get the real, non-decorated object
34
+ def project
35
+ __getobj__
36
+ end
37
+
38
+
39
+ def cult(*argv)
40
+ system $0, *argv
41
+ end
42
+
43
+
44
+ def binding
45
+ super
46
+ end
47
+ end
48
+
49
+ module_function
50
+ def console_cmd
51
+ Cri::Command.define do
52
+ name 'console'
53
+ summary 'Launch an REPL with you project loaded'
54
+ description <<~EOD.format_description
55
+ The Cult console loads your project, and starts a Ruby REPL. This can
56
+ be useful for troubleshooting, or just poking around the project.
57
+
58
+ A few convenience global variables are set to inspect.
59
+ EOD
60
+
61
+ flag :i, :irb, 'IRB (default)'
62
+ flag :r, :ripl, 'Ripl'
63
+ flag :p, :pry, 'Pry'
64
+ flag nil, :reexec, 'Console has been exec\'d for a reload'
65
+
66
+ run(arguments: 0) do |opts, args, cmd|
67
+ context = ConsoleContext.new(Cult.project, ARGV)
68
+
69
+ if opts[:reexec]
70
+ $stderr.puts "Reloaded."
71
+ else
72
+ $stderr.puts <<~EOD
73
+
74
+ Welcome to the #{Rainbow('Cult').green} Console.
75
+
76
+ Your project has been made accessible via 'project', and forwards
77
+ via 'self':
78
+
79
+ => #{context.inspect}
80
+
81
+ Useful methods: nodes, roles, providers
82
+
83
+ EOD
84
+ end
85
+
86
+ context.load_rc
87
+
88
+ if opts[:ripl]
89
+ require 'ripl'
90
+ ARGV.clear
91
+ # Look, something reasonable:
92
+ Ripl.start(binding: context.binding)
93
+
94
+ elsif opts[:pry]
95
+ require 'pry'
96
+ context.binding.pry
97
+ else
98
+ # irb: This is ridiculous.
99
+ require 'irb'
100
+ ARGV.clear
101
+ IRB.setup(nil)
102
+
103
+ irb = IRB::Irb.new(IRB::WorkSpace.new(context.binding))
104
+ IRB.conf[:MAIN_CONTEXT] = irb.context
105
+ IRB.conf[:IRB_RC].call(irb.context) if IRB.conf[:IRB_RC]
106
+
107
+ trap("SIGINT") do
108
+ irb.signal_handle
109
+ end
110
+
111
+ begin
112
+ catch(:IRB_EXIT) do
113
+ irb.eval_input
114
+ end
115
+ ensure
116
+ IRB::irb_at_exit
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ end
124
+ end
@@ -0,0 +1,84 @@
1
+ require 'cri'
2
+
3
+ module Cult
4
+ module CLI
5
+ class ::String
6
+ def format_description
7
+ self.gsub(/(\S)\n(\S)/m, '\1 \2')
8
+ .gsub(/\.[ ]{2}(\S)/m, '. \1')
9
+ end
10
+ end
11
+
12
+
13
+ module CommandExtensions
14
+ def project_required?
15
+ defined?(@project_required) ? @project_required : true
16
+ end
17
+
18
+
19
+ def project_required=(v)
20
+ @project_required = v
21
+ end
22
+
23
+
24
+ attr_accessor :argument_spec
25
+
26
+
27
+ # This function returns a wrapped version of the block passed to `run`
28
+ def block
29
+ lambda do |opts, args, cmd|
30
+ if project_required? && Cult.project.nil?
31
+ fail CLIError, "command '#{name}' requires a Cult project"
32
+ end
33
+
34
+ check_argument_spec!(args, argument_spec) if argument_spec
35
+
36
+ super.call(opts, args, cmd)
37
+ end
38
+ end
39
+
40
+
41
+ def check_argument_spec!(args, range)
42
+ range = (range..range) if range.is_a?(Integer)
43
+ if range.end == -1
44
+ range = range.begin .. Float::INFINITY
45
+ end
46
+
47
+ unless range.cover?(args.size)
48
+ msg = case
49
+ when range.size == 1 && range.begin == 0
50
+ "accepts no arguments"
51
+ when range.size == 1 && range.begin == 1
52
+ "accepts one argument"
53
+ when range.begin == range.end
54
+ "accepts exactly #{range.begin} arguments"
55
+ else
56
+ if range.end == Float::INFINITY
57
+ "requires #{range.begin}+ arguments"
58
+ else
59
+ "accepts #{range} arguments"
60
+ end
61
+ end
62
+ fail CLIError, "Command #{msg}"
63
+ end
64
+ end
65
+
66
+ Cri::Command.prepend(self)
67
+ end
68
+
69
+
70
+ module CommandDSLExtensions
71
+ def optional_project
72
+ @command.project_required = false
73
+ end
74
+
75
+
76
+ def run(arguments: nil, &block)
77
+ @command.argument_spec = arguments if arguments
78
+ super(&block)
79
+ end
80
+
81
+ Cri::CommandDSL.prepend(self)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,116 @@
1
+ require 'cult/skel'
2
+ require 'cult/project'
3
+ require 'cult/drivers/load'
4
+
5
+ module Cult
6
+ module CLI
7
+
8
+ module_function
9
+ def init_cmd
10
+ Cri::Command.define do
11
+ drivers = Cult::Drivers.all.map{|d| d.driver_name }.join ", "
12
+
13
+ optional_project
14
+ name 'init'
15
+ aliases 'new'
16
+ usage 'init DIRECTORY'
17
+ summary 'Create a new Cult project'
18
+ description <<~EOD.format_description
19
+ Generates a new Cult project, based on a project skeleton.
20
+
21
+ The most useful option is --driver, which both specifies a driver and
22
+ sets up a provider of the same name. This will make sure the
23
+ dependencies for using the driver are install, and any bookkeeping
24
+ required to start interacting with your VPS provider is handled.
25
+
26
+ This usually involves entering an account name or getting an API key.
27
+
28
+ The default provider is "script", which isn't too pleasant, but has
29
+ no dependencies. The "script" driver manages your fleet by executing
30
+ scripts in $CULT_PROJECT/script, which you have to implement. This is
31
+ tedious, but very doable. However, if Cult knows about your provider,
32
+ it can handle all of this without you having to do anything.
33
+
34
+ Cult knows about the following providers:
35
+
36
+ > #{drivers}
37
+
38
+ The init process just gets you started, and it's nothing that couldn't
39
+ be accomplished by hand, so if you want to change anything later, it's
40
+ not a big deal.
41
+
42
+ The project generated sets up a pretty common configuration: an 'all'
43
+ role, a 'bootstrap' role, and a demo task that puts a colorful banner
44
+ in each node's MOTD.
45
+ EOD
46
+
47
+ required :d, :driver, 'Driver with which to create your provider'
48
+ required :p, :provider, 'Specify an explicit provider name'
49
+ flag :g, :git, 'Enable Git integration'
50
+
51
+ run(arguments: 1) do |opts, args, cmd|
52
+ project = Project.new(args[0])
53
+ if project.exist?
54
+ fail CLIError, "a Cult project already exists in #{project.path}"
55
+ end
56
+
57
+ project.git_integration = opts[:git]
58
+
59
+ driver_cls = if !opts[:provider] && !opts[:driver]
60
+ opts[:provider] ||= 'scripts'
61
+ CLI.fetch_item(opts[:provider], from: Driver)
62
+ elsif opts[:provider] && !opts[:driver]
63
+ CLI.fetch_item(opts[:provider], from: Driver)
64
+ elsif opts[:driver] && !opts[:provider]
65
+ CLI.fetch_item(opts[:driver], from: Driver).tap do |dc|
66
+ opts[:provider] = dc.driver_name
67
+ end
68
+ elsif opts[:driver]
69
+ CLI.fetch_item(opts[:driver], from: Driver)
70
+ end
71
+
72
+ fail CLIError, "Hmm, no driver class" if driver_cls.nil?
73
+
74
+ skel = Skel.new(project)
75
+ skel.copy!
76
+
77
+ provider_conf = {
78
+ name: opts[:provider],
79
+ driver: driver_cls.driver_name
80
+ }
81
+
82
+ CLI.offer_gem_install do
83
+ driver_conf = driver_cls.setup!
84
+ provider_conf.merge!(driver_conf)
85
+
86
+
87
+ provider_dir = File.join(project.location_of("providers"),
88
+ provider_conf[:name])
89
+ FileUtils.mkdir_p(provider_dir)
90
+
91
+
92
+ provider_file = File.join(provider_dir,
93
+ project.dump_name("provider"))
94
+ File.write(provider_file, project.dump_object(provider_conf))
95
+
96
+
97
+ defaults_file = File.join(provider_dir,
98
+ project.dump_name("defaults"))
99
+ defaults = Provider.generate_defaults(provider_conf)
100
+ File.write(defaults_file, project.dump_object(defaults))
101
+ end
102
+
103
+ if opts[:git]
104
+ Dir.chdir(project.path) do
105
+ `git init .`
106
+ `git add -A`
107
+ `git commit -m "[Cult] Created new project"`
108
+ end
109
+ end
110
+
111
+ end
112
+
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,26 @@
1
+ require 'cri'
2
+ require 'cult/cli/cri_extensions'
3
+
4
+ module Cult
5
+ module CLI
6
+
7
+ module_function
8
+ def load_commands!
9
+ Dir.glob(File.join(__dir__, "*_cmd.rb")).each do |file|
10
+ require file
11
+ end
12
+ end
13
+
14
+
15
+ def commands
16
+ Cult::CLI.methods(false).select do |m|
17
+ m.to_s.match(/_cmd\z/)
18
+ end.map do |m|
19
+ Cult::CLI.send(m)
20
+ end
21
+ end
22
+
23
+ end
24
+ end
25
+
26
+ Cult::CLI.load_commands!