vps 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,110 @@
1
+ require "vps/cli/playbook/state"
2
+ require "vps/cli/playbook/tasks"
3
+
4
+ module VPS
5
+ class CLI < Thor
6
+ class Playbook
7
+
8
+ class NotFoundError < VPS::CLI::Error; end
9
+
10
+ YML = ".yml"
11
+
12
+ attr_reader :command
13
+
14
+ def self.all
15
+ Dir["#{VPS::PLAYBOOKS}/*#{YML}"].collect do |playbook|
16
+ command = File.basename(playbook, YML)
17
+ new(playbook, command)
18
+ end
19
+ end
20
+
21
+ def self.run(playbook, state)
22
+ playbook = File.expand_path(playbook, VPS::PLAYBOOKS)
23
+
24
+ if File.directory?(playbook)
25
+ playbook += "/#{state.server_version}"
26
+ end
27
+ unless File.extname(playbook) == YML
28
+ playbook += YML
29
+ end
30
+
31
+ new(playbook).run(state)
32
+ end
33
+
34
+ def initialize(playbook, command = nil)
35
+ unless File.exists?(playbook)
36
+ raise NotFoundError, "Could not find playbook #{playbook.inspect}"
37
+ end
38
+
39
+ @playbook = {"constants" => {}}.merge(YAML.load_file(playbook))
40
+ unless (playbooks = Dir[playbook.gsub(/\.\w+$/, "/*")].collect{|yml| File.basename(yml, ".yml")}).empty?
41
+ @playbook["constants"]["playbooks"] = playbooks
42
+ end
43
+
44
+ @command = command
45
+ end
46
+
47
+ def description
48
+ playbook["description"]
49
+ end
50
+
51
+ def usage
52
+ playbook["usage"] || arguments.collect(&:upcase).unshift(@command).join(" ")
53
+ end
54
+
55
+ def arguments
56
+ playbook["arguments"] || []
57
+ end
58
+
59
+ def constants
60
+ playbook["constants"] || {}
61
+ end
62
+
63
+ def options
64
+ options = playbook["options"] || {}
65
+ options[%w(-d --dry-run)] = :boolean
66
+ options
67
+ end
68
+
69
+ def tasks
70
+ @tasks ||= begin
71
+ tasks = [playbook["tasks"]].flatten.compact
72
+
73
+ if requires_confirmation?
74
+ tasks.unshift({
75
+ "task" => :confirm,
76
+ "question" => playbook["confirm"],
77
+ "indent" => false,
78
+ "n" => :abort
79
+ })
80
+ end
81
+
82
+ Tasks.new(tasks)
83
+ end
84
+ end
85
+
86
+ def run!(args, options)
87
+ hash = Hash[arguments.zip(args)]
88
+ state = State.new(hash.merge(options))
89
+ run(state)
90
+ end
91
+
92
+ def run(state)
93
+ state.scope(constants) do
94
+ tasks.run(state)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def playbook
101
+ @playbook
102
+ end
103
+
104
+ def requires_confirmation?
105
+ playbook["confirm"].to_s.strip != ""
106
+ end
107
+
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,170 @@
1
+ module VPS
2
+ class CLI < Thor
3
+ class Playbook
4
+ class State
5
+
6
+ class AuthenticationFailedError < VPS::CLI::Error; end
7
+ class SSHMock
8
+ def initialize
9
+ puts "🏄‍♀️ ~> ".gray + "Mocking SSH connection with Ubuntu 18.04.2 LTS server".cyan
10
+ end
11
+ def exec!(command)
12
+ case command
13
+ when "cat /etc/lsb-release"
14
+ <<-LSB
15
+ DISTRIB_ID=Ubuntu
16
+ DISTRIB_RELEASE=18.04
17
+ DISTRIB_CODENAME=bionic
18
+ DISTRIB_DESCRIPTION="Ubuntu 18.04.2 LTS"
19
+ LSB
20
+ when "pwd"
21
+ "/home/myapp"
22
+ else
23
+ raise "Encountered unexpected command: #{command}"
24
+ end
25
+ end
26
+ end
27
+
28
+ SERVER_VERSION = "SERVER_VERSION"
29
+
30
+ def initialize(hash = {})
31
+ @stack = [hash.with_indifferent_access]
32
+ end
33
+
34
+ def dry_run?
35
+ !!fetch(:d)
36
+ end
37
+
38
+ def scope(constants = {})
39
+ stack.unshift(constants.with_indifferent_access)
40
+ constants.keys.each do |key|
41
+ self[key] = resolve(self[key])
42
+ end
43
+ yield
44
+ stack.shift
45
+ end
46
+
47
+ def fetch(key, default = nil)
48
+ stack.each do |hash|
49
+ return hash[key] if hash.key?(key)
50
+ end
51
+ default
52
+ end
53
+
54
+ def [](path)
55
+ to_domain = !!(path = path.dup).gsub!("domain:", "") if path.is_a?(String)
56
+ path.to_s.split(".").inject(self) do |hash, key|
57
+ (hash || {}).fetch(key)
58
+ end.tap do |value|
59
+ if to_domain && value
60
+ if (domain = value[:domains].first)
61
+ return domain.gsub(/https?:\/\//, "")
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def []=(key, value)
68
+ stack.first[key] = value
69
+ end
70
+
71
+ def to_binding(object = self)
72
+ case object
73
+ when State
74
+ keys = stack.collect(&:keys).flatten.uniq
75
+ keys.inject({state: object}) do |hash, key|
76
+ hash[key] = to_binding(self[key])
77
+ hash
78
+ end
79
+ when Hash
80
+ hash = object.inject({}) do |hash, (key, value)|
81
+ hash[key] = to_binding(resolve(value))
82
+ hash
83
+ end
84
+ OpenStruct.new(hash)
85
+ when Array
86
+ object.collect{|object| to_binding(object)}
87
+ else
88
+ object
89
+ end
90
+ end
91
+
92
+ def resolve(arg)
93
+ if arg.is_a?(String)
94
+ if arg.match(/^<<\s*(.*?)\s*>>$/)
95
+ self[$1]
96
+ else
97
+ arg.gsub(/\{\{(\{?)\s*(.*?)\s*\}\}\}?/) do
98
+ value = self[$2]
99
+ ($1 == "{") ? value.inspect : value
100
+ end
101
+ end
102
+ else
103
+ arg
104
+ end
105
+ end
106
+
107
+ def execute(command, user = nil)
108
+ if user
109
+ command = "sudo -u #{user} -H sh -c #{command.inspect}"
110
+ end
111
+ puts "🏄‍♀️ ~> ".gray + command.yellow
112
+ unless dry_run?
113
+ start = Time.now
114
+ result = []
115
+
116
+ channel = ssh.open_channel do |ch|
117
+ ch.exec(command) do |ch|
118
+ ch.on_data do |_, data|
119
+ unless data.blank?
120
+ data = data.split("\n").reject(&:blank?)
121
+ puts " " + data.join("\n ")
122
+ result.concat data
123
+ end
124
+ end
125
+ end
126
+ end
127
+ channel.wait
128
+
129
+ puts " #{(Time.now - start).round(3)}s".gray
130
+ result.join("\n")
131
+ end
132
+ end
133
+
134
+ def home_directory
135
+ @home_directory ||= ssh.exec!("pwd").strip
136
+ end
137
+
138
+ def server_version
139
+ @server_version ||= begin
140
+ release = ssh.exec!("cat /etc/lsb-release")
141
+
142
+ distribution = release.match(/DISTRIB_ID=(.*)/)[1].underscore
143
+ release = release.match(/DISTRIB_RELEASE=(.*)/)[1]
144
+
145
+ [distribution, release].join("-")
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def stack
152
+ @stack
153
+ end
154
+
155
+ def ssh
156
+ @ssh ||= begin
157
+ if dry_run?
158
+ SSHMock.new
159
+ else
160
+ Net::SSH.start(fetch(:host), fetch(:user))
161
+ end
162
+ end
163
+ rescue StandardError => e
164
+ raise AuthenticationFailedError, e.message
165
+ end
166
+
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,262 @@
1
+ module VPS
2
+ class CLI < Thor
3
+ class Playbook
4
+ class Tasks
5
+
6
+ class InvalidTaskError < VPS::CLI::Error; end
7
+
8
+ def self.available
9
+ public_instance_methods(false) - [:run]
10
+ end
11
+
12
+ def initialize(tasks)
13
+ @tasks = [tasks].flatten.compact
14
+ end
15
+
16
+ def run(state)
17
+ @tasks.collect do |task|
18
+ case task
19
+ when :continue # next
20
+ when :abort
21
+ raise Interrupt
22
+ else
23
+ run_task(state, task)
24
+ end
25
+ end
26
+ end
27
+
28
+ def run_tasks(state, options)
29
+ tasks = (state.resolve(options[:tasks]) || []).compact
30
+ Tasks.new(tasks).run(state)
31
+ end
32
+
33
+ def ensure(state, options)
34
+ argument = state.resolve(options[:argument])
35
+
36
+ if state[argument].blank?
37
+ options[:fallbacks].each do |task|
38
+ unless (value = run_task(state, task.merge(as: argument))).blank?
39
+ set(state, argument, value)
40
+ break
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def obtain_config(state, options)
47
+ VPS.read_config(state[:host]).tap do |config|
48
+ config.each do |key, value|
49
+ set(state, key, value)
50
+ end
51
+ end
52
+ end
53
+
54
+ def read_config(state, options)
55
+ VPS.read_config(state[:host], state.resolve(options[:key]))
56
+ end
57
+
58
+ def write_config(state, options)
59
+ config = {}
60
+
61
+ options[:config].each do |key, spec|
62
+ spec = spec.with_indifferent_access if spec.is_a?(Hash)
63
+
64
+ if spec.is_a?(Hash) && spec[:task]
65
+ config[key] = run_task(state, spec)
66
+ else
67
+ config[key] = state.resolve(spec)
68
+ end
69
+ end
70
+
71
+ unless state.dry_run?
72
+ VPS.write_config(state[:host], config)
73
+ end
74
+ end
75
+
76
+ def loop(state, options)
77
+ if (collection = (state.resolve(options[:through]) || []).compact).any?
78
+ puts_description(state, options)
79
+ as = state.resolve(options[:as])
80
+ collection.each do |item|
81
+ state.scope({as => item}) do
82
+ run_tasks(state, {:tasks => options[:run]})
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def when(state, options)
89
+ if state[options[:boolean]]
90
+ puts_description(state, options)
91
+ run_tasks(state, {:tasks => options[:run]})
92
+ end
93
+ end
94
+
95
+ def confirm(state, options)
96
+ answer = Ask.confirm(question(options)) ? "y" : "n"
97
+ tasks = options[answer]
98
+ set(state, options, answer)
99
+ run_tasks(state, {:tasks => tasks})
100
+ end
101
+
102
+ def select(state, options)
103
+ list = state.resolve(options[:options])
104
+ index = Ask.list(question(options), list)
105
+ set(state, options, list[index])
106
+ end
107
+
108
+ def multiselect(state, options)
109
+ names, labels, defaults = [], [], []
110
+
111
+ options[:options].inject([names, labels, defaults]) do |_, (name, label)|
112
+ default = true
113
+ label = label.gsub(/ \[false\]$/) do
114
+ default = false
115
+ ""
116
+ end
117
+ names.push(name)
118
+ labels.push(label)
119
+ defaults.push(default)
120
+ end
121
+
122
+ selected = Ask.checkbox(question(options), labels, default: defaults)
123
+ selected.each_with_index do |value, index|
124
+ name = names[index]
125
+ state[name] = value
126
+ end
127
+ end
128
+
129
+ def input(state, options)
130
+ answer = Ask.input(question(options), default: state.resolve(options[:default]))
131
+ set(state, options, answer)
132
+ end
133
+
134
+ def generate_file(state, options)
135
+ erb = VPS.read_template(state.resolve(options[:template]))
136
+ template = Erubis::Eruby.new(erb)
137
+ content = template.result(state.to_binding)
138
+
139
+ unless state.dry_run?
140
+ if target = state.resolve(options[:target])
141
+ target = File.expand_path(target)
142
+ FileUtils.mkdir_p(File.dirname(target))
143
+ File.open(target, "w") do |file|
144
+ file.write(content)
145
+ end
146
+ end
147
+ end
148
+
149
+ content
150
+ end
151
+
152
+ def execute(state, options)
153
+ output = [options[:command]].flatten.inject(nil) do |_, command|
154
+ command = state.resolve(command)
155
+ puts "☕ ~> ".gray + command.yellow
156
+ unless state.dry_run?
157
+ start = Time.now
158
+ result = []
159
+
160
+ IO.popen(command).each do |data|
161
+ unless data.blank?
162
+ data = data.split("\n").reject(&:blank?)
163
+ puts " " + data.join("\n ")
164
+ result.concat data
165
+ end
166
+ end
167
+
168
+ puts " #{(Time.now - start).round(3)}s".gray
169
+ result.join("\n")
170
+ end
171
+ end
172
+ set(state, options, output)
173
+ end
174
+
175
+ def remote_execute(state, options)
176
+ user = state.resolve(options[:user])
177
+ output = [options[:command]].flatten.inject(nil) do |_, command|
178
+ command = state.resolve(command)
179
+ state.execute(command, user)
180
+ end
181
+ set(state, options, output)
182
+ end
183
+
184
+ def upload(state, options)
185
+ host = state[:host]
186
+
187
+ file = state.resolve(options[:file])
188
+ remote_path = options[:remote_path] ? state.resolve(options[:remote_path]) : file
189
+ file = "-r #{file}" if File.directory?(file)
190
+
191
+ return if file.blank?
192
+
193
+ remote_path = remote_path.gsub("~", state.home_directory)
194
+
195
+ remote_execute(state, {:command => "mkdir -p #{File.dirname(remote_path)}"})
196
+ execute(state, {:command => "scp #{file} #{host}:#{remote_path} > /dev/tty"})
197
+ end
198
+
199
+ def sync(state, options)
200
+ host = state[:host]
201
+
202
+ directory = state.resolve(options[:directory])
203
+ remote_path = options[:remote_path] ? state.resolve(options[:remote_path]) : directory
204
+
205
+ return if directory.blank?
206
+
207
+ remote_path = remote_path.gsub("~", state.home_directory)
208
+
209
+ remote_execute(state, {:command => "mkdir -p #{File.dirname(remote_path)}"})
210
+ execute(state, {:command => "rsync #{options[:options]} #{directory} #{host}:#{remote_path} > /dev/tty"})
211
+ end
212
+
213
+ def playbook(state, options)
214
+ Playbook.run(state.resolve(options[:playbook]), state)
215
+ end
216
+
217
+ def print(state, options)
218
+ puts state.resolve(options[:message])
219
+ end
220
+
221
+ private
222
+
223
+ def run_task(state, task)
224
+ name, options = derive_task(task)
225
+ if name
226
+ puts_description(state, options) unless [:when, :loop].include?(name)
227
+ send(name, state, options)
228
+ else
229
+ raise InvalidTaskError, "Invalid task #{task.inspect}"
230
+ end
231
+ end
232
+
233
+ def derive_task(task)
234
+ if task.is_a?(Hash)
235
+ task = task.with_indifferent_access
236
+ name = task.delete(:task).to_sym
237
+ if Tasks.available.include?(name)
238
+ [name, task]
239
+ end
240
+ end
241
+ end
242
+
243
+ def puts_description(state, options)
244
+ if description = state.resolve(options[:description])
245
+ puts "\n== ".yellow + description.green
246
+ end
247
+ end
248
+
249
+ def question(options)
250
+ (options[:indent] == false ? "" : " ") + options[:question]
251
+ end
252
+
253
+ def set(state, as, value)
254
+ if key = (as.is_a?(Hash) ? as[:as] : as)
255
+ state[key] = value
256
+ end
257
+ end
258
+
259
+ end
260
+ end
261
+ end
262
+ end