cult 0.1.1.pre

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.
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!