vps 0.2.0
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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +107 -0
- data/Rakefile +10 -0
- data/VERSION +1 -0
- data/bin/vps +10 -0
- data/config/services.yml +63 -0
- data/lib/vps.rb +88 -0
- data/lib/vps/cli.rb +62 -0
- data/lib/vps/cli/domain.rb +59 -0
- data/lib/vps/cli/playbook.rb +110 -0
- data/lib/vps/cli/playbook/state.rb +170 -0
- data/lib/vps/cli/playbook/tasks.rb +262 -0
- data/lib/vps/cli/service.rb +96 -0
- data/lib/vps/cli/upstream.rb +95 -0
- data/lib/vps/core_ext/ostruct.rb +17 -0
- data/lib/vps/core_ext/string.rb +33 -0
- data/lib/vps/version.rb +7 -0
- data/playbooks/deploy.yml +43 -0
- data/playbooks/deploy/docker.yml +96 -0
- data/playbooks/init.yml +12 -0
- data/playbooks/init/ubuntu-18.04.yml +110 -0
- data/playbooks/install.yml +18 -0
- data/playbooks/install/docker/ubuntu-18.04.yml +35 -0
- data/script/console +7 -0
- data/templates/docker/data/nginx/app.conf.erb +69 -0
- data/templates/docker/docker-compose.yml.erb +58 -0
- data/templates/docker/upstream/Dockerfile.phoenix.erb +13 -0
- data/templates/docker/upstream/Dockerfile.plug.erb +13 -0
- data/templates/docker/upstream/Dockerfile.rack.erb +16 -0
- data/templates/docker/upstream/Dockerfile.rails.erb +18 -0
- data/templates/docker/upstream/init-letsencrypt.sh.erb +76 -0
- data/test/test_helper.rb +12 -0
- data/test/test_helper/coverage.rb +8 -0
- data/test/unit/test_version.rb +15 -0
- data/vps.gemspec +29 -0
- metadata +214 -0
@@ -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
|