vps 0.1.1

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