hetzner-bootstrap 1.0.2 → 2.0.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/.rspec +2 -0
- data/.rubocop.yml +21 -0
- data/.ruby-version +1 -0
- data/Gemfile +3 -1
- data/LICENSE +1 -1
- data/README.md +116 -10
- data/Rakefile +2 -0
- data/example.rb +95 -96
- data/hetzner-bootstrap.gemspec +22 -16
- data/lib/hetzner-bootstrap.rb +23 -25
- data/lib/hetzner/bootstrap/target.rb +87 -86
- data/lib/hetzner/bootstrap/template.rb +7 -4
- data/lib/hetzner/bootstrap/version.rb +3 -1
- data/spec/hetzner_bootstrap_spec.rb +26 -31
- data/spec/spec_helper.rb +99 -0
- metadata +94 -52
data/lib/hetzner-bootstrap.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'benchmark'
|
2
4
|
require 'logger'
|
3
5
|
|
@@ -8,14 +10,11 @@ require 'hetzner/bootstrap/template'
|
|
8
10
|
|
9
11
|
module Hetzner
|
10
12
|
class Bootstrap
|
11
|
-
attr_accessor :targets
|
12
|
-
attr_accessor :api
|
13
|
-
attr_accessor :actions
|
14
|
-
attr_accessor :logger
|
13
|
+
attr_accessor :targets, :api, :actions, :logger
|
15
14
|
|
16
15
|
def initialize(options = {})
|
17
16
|
@targets = []
|
18
|
-
@actions = %w
|
17
|
+
@actions = %w[enable_rescue_mode
|
19
18
|
reset
|
20
19
|
wait_for_ssh_down
|
21
20
|
wait_for_ssh_up
|
@@ -26,48 +25,47 @@ module Hetzner
|
|
26
25
|
verify_installation
|
27
26
|
copy_ssh_keys
|
28
27
|
update_local_known_hosts
|
29
|
-
post_install
|
28
|
+
post_install
|
29
|
+
post_install_remote]
|
30
30
|
@api = options[:api]
|
31
|
-
@logger = options[:logger] || Logger.new(
|
31
|
+
@logger = options[:logger] || Logger.new($stdout)
|
32
32
|
end
|
33
33
|
|
34
34
|
def add_target(param)
|
35
|
-
if param.is_a? Hetzner::Bootstrap::Target
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
35
|
+
@targets << if param.is_a? Hetzner::Bootstrap::Target
|
36
|
+
param
|
37
|
+
else
|
38
|
+
Hetzner::Bootstrap::Target.new(param)
|
39
|
+
end
|
40
40
|
end
|
41
41
|
|
42
42
|
def <<(param)
|
43
43
|
add_target param
|
44
44
|
end
|
45
45
|
|
46
|
-
def bootstrap!(
|
46
|
+
def bootstrap!(_options = {})
|
47
47
|
@targets.each do |target|
|
48
|
-
#fork do
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
#end
|
48
|
+
# fork do
|
49
|
+
target.use_api @api
|
50
|
+
target.use_logger @logger
|
51
|
+
bootstrap_one_target! target
|
52
|
+
# end
|
53
53
|
end
|
54
|
-
#Process.waitall
|
54
|
+
# Process.waitall
|
55
55
|
end
|
56
56
|
|
57
57
|
def bootstrap_one_target!(target)
|
58
58
|
actions = (target.actions || @actions)
|
59
|
-
actions.each_with_index do |action,
|
60
|
-
|
59
|
+
actions.each_with_index do |action, _index|
|
61
60
|
loghack = "\b" * 24 # remove: "[bootstrap_one_target!] ".length
|
62
|
-
target.logger.info "#{loghack}[#{action}] #{
|
61
|
+
target.logger.info "#{loghack}[#{action}] #{format '%-20s', 'START'}"
|
63
62
|
d = Benchmark.realtime do
|
64
63
|
target.send action
|
65
64
|
end
|
66
|
-
target.logger.info "#{loghack}[#{action}] FINISHED in #{
|
65
|
+
target.logger.info "#{loghack}[#{action}] FINISHED in #{format '%.5f', d} seconds"
|
67
66
|
end
|
68
|
-
rescue => e
|
67
|
+
rescue StandardError => e
|
69
68
|
puts "something bad happened unexpectedly: #{e.class} => #{e.message}"
|
70
69
|
end
|
71
70
|
end
|
72
71
|
end
|
73
|
-
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'erubis'
|
2
4
|
require 'net/ssh'
|
3
5
|
require 'socket'
|
@@ -6,34 +8,23 @@ require 'timeout'
|
|
6
8
|
module Hetzner
|
7
9
|
class Bootstrap
|
8
10
|
class Target
|
9
|
-
attr_accessor :ip
|
10
|
-
|
11
|
-
|
12
|
-
attr_accessor :template
|
13
|
-
attr_accessor :rescue_os
|
14
|
-
attr_accessor :rescue_os_bit
|
15
|
-
attr_accessor :actions
|
16
|
-
attr_accessor :hostname
|
17
|
-
attr_accessor :post_install
|
18
|
-
attr_accessor :public_keys
|
19
|
-
attr_accessor :bootstrap_cmd
|
20
|
-
attr_accessor :logger
|
21
|
-
|
11
|
+
attr_accessor :ip, :login, :password, :template, :rescue_os, :rescue_os_bit, :actions, :hostname, :public_keys, :bootstrap_cmd, :logger
|
12
|
+
attr_writer :post_install, :post_install_remote
|
13
|
+
|
22
14
|
def initialize(options = {})
|
23
|
-
@rescue_os
|
24
|
-
@rescue_os_bit
|
25
|
-
@retries
|
26
|
-
@bootstrap_cmd
|
27
|
-
@login
|
28
|
-
|
29
|
-
if tmpl = options.delete(:template)
|
30
|
-
@template = Template.new tmpl
|
31
|
-
else
|
32
|
-
raise NoTemplateProvidedError.new 'No imageinstall template provided.'
|
33
|
-
end
|
15
|
+
@rescue_os = 'linux'
|
16
|
+
@rescue_os_bit = '64'
|
17
|
+
@retries = 0
|
18
|
+
@bootstrap_cmd = 'export TERM=xterm; /root/.oldroot/nfs/install/installimage -a -c /tmp/template'
|
19
|
+
@login = 'root'
|
20
|
+
@post_install_remote = ''
|
34
21
|
|
35
|
-
options.
|
36
|
-
|
22
|
+
@template = Template.new options.delete(:template)
|
23
|
+
|
24
|
+
raise NoTemplateProvidedError 'No imageinstall template provided.' unless @template
|
25
|
+
|
26
|
+
options.each_pair do |k, v|
|
27
|
+
send("#{k}=", v)
|
37
28
|
end
|
38
29
|
end
|
39
30
|
|
@@ -45,14 +36,14 @@ module Hetzner
|
|
45
36
|
reset_retries
|
46
37
|
logger.info "IP: #{ip} => password: #{@password}"
|
47
38
|
elsif @retries > 3
|
48
|
-
logger.error
|
39
|
+
logger.error 'rescue system could not be activated'
|
49
40
|
raise CantActivateRescueSystemError, result
|
50
41
|
else
|
51
42
|
@retries += 1
|
52
43
|
|
53
44
|
logger.warn "problem while trying to activate rescue system (retries: #{@retries})"
|
54
45
|
@api.disable_rescue! @ip
|
55
|
-
|
46
|
+
|
56
47
|
rolling_sleep
|
57
48
|
enable_rescue_mode options
|
58
49
|
end
|
@@ -64,7 +55,7 @@ module Hetzner
|
|
64
55
|
if result.success?
|
65
56
|
reset_retries
|
66
57
|
elsif @retries > 3
|
67
|
-
logger.error
|
58
|
+
logger.error 'resetting through webservice failed.'
|
68
59
|
raise CantResetSystemError, result
|
69
60
|
else
|
70
61
|
@retries += 1
|
@@ -74,93 +65,90 @@ module Hetzner
|
|
74
65
|
end
|
75
66
|
end
|
76
67
|
|
77
|
-
def port_open?
|
68
|
+
def port_open?(ip, port)
|
78
69
|
ssh_port_probe = TCPSocket.new ip, port
|
79
70
|
IO.select([ssh_port_probe], nil, nil, 2)
|
80
71
|
ssh_port_probe.close
|
81
72
|
true
|
82
73
|
end
|
83
74
|
|
84
|
-
def wait_for_ssh_down
|
75
|
+
def wait_for_ssh_down
|
85
76
|
loop do
|
86
77
|
sleep 2
|
87
|
-
Timeout
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
raise Errno::ECONNREFUSED
|
92
|
-
end
|
78
|
+
Timeout.timeout(4) do
|
79
|
+
raise Errno::ECONNREFUSED unless port_open? @ip, 22
|
80
|
+
|
81
|
+
logger.debug 'SSH UP'
|
93
82
|
end
|
94
83
|
end
|
95
84
|
rescue Timeout::Error, Errno::ECONNREFUSED
|
96
|
-
logger.debug
|
85
|
+
logger.debug 'SSH DOWN'
|
97
86
|
end
|
98
87
|
|
99
|
-
def wait_for_ssh_up
|
88
|
+
def wait_for_ssh_up
|
100
89
|
loop do
|
101
|
-
Timeout
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
raise Errno::ECONNREFUSED
|
107
|
-
end
|
90
|
+
Timeout.timeout(4) do
|
91
|
+
raise Errno::ECONNREFUSED unless port_open? @ip, 22
|
92
|
+
|
93
|
+
logger.debug 'SSH UP'
|
94
|
+
return true
|
108
95
|
end
|
109
96
|
end
|
110
97
|
rescue Errno::ECONNREFUSED, Timeout::Error
|
111
|
-
logger.debug
|
98
|
+
logger.debug 'SSH DOWN'
|
112
99
|
sleep 2
|
113
100
|
retry
|
114
101
|
end
|
115
102
|
|
116
|
-
def installimage
|
103
|
+
def installimage
|
117
104
|
template = render_template
|
118
105
|
|
119
106
|
remote do |ssh|
|
120
107
|
ssh.exec! "echo \"#{template}\" > /tmp/template"
|
121
108
|
logger.info "remote executing: #{@bootstrap_cmd}"
|
122
109
|
output = ssh.exec!(@bootstrap_cmd)
|
123
|
-
logger.info output
|
110
|
+
logger.info output.gsub(`clear`, '')
|
124
111
|
end
|
125
112
|
end
|
126
113
|
|
127
|
-
def reboot
|
114
|
+
def reboot
|
128
115
|
remote do |ssh|
|
129
|
-
ssh.exec!(
|
116
|
+
ssh.exec!('reboot')
|
130
117
|
end
|
118
|
+
rescue IOError, Net::SSH::Disconnect
|
119
|
+
logger.debug 'SSH connection was closed as anticipated.'
|
131
120
|
end
|
132
121
|
|
133
|
-
def verify_installation
|
122
|
+
def verify_installation
|
134
123
|
remote do |ssh|
|
135
|
-
working_hostname = ssh.exec!(
|
136
|
-
|
137
|
-
|
138
|
-
end
|
124
|
+
working_hostname = ssh.exec!('cat /etc/hostname')
|
125
|
+
working_hostname.chomp!
|
126
|
+
logger.debug "hostnames do not match: assumed #{@hostname} but received #{working_hostname}" unless @hostname == working_hostname.chomp
|
139
127
|
end
|
140
128
|
end
|
141
129
|
|
142
|
-
def copy_ssh_keys
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
130
|
+
def copy_ssh_keys
|
131
|
+
return unless @public_keys
|
132
|
+
|
133
|
+
remote do |ssh|
|
134
|
+
ssh.exec!('mkdir /root/.ssh')
|
135
|
+
Array(@public_keys).each do |key|
|
136
|
+
pub = File.read(File.expand_path(key))
|
137
|
+
ssh.exec!("echo \"#{pub}\" >> /root/.ssh/authorized_keys")
|
150
138
|
end
|
151
139
|
end
|
152
140
|
end
|
153
141
|
|
154
|
-
def update_local_known_hosts
|
155
|
-
remote(:
|
142
|
+
def update_local_known_hosts
|
143
|
+
remote(verify_host_key: :accept_new_or_local_tunnel) do |ssh|
|
156
144
|
# dummy
|
157
145
|
end
|
158
146
|
rescue Net::SSH::HostKeyMismatch => e
|
159
147
|
e.remember_host!
|
160
|
-
logger.info
|
148
|
+
logger.info 'remote host key added to local ~/.ssh/known_hosts file.'
|
161
149
|
end
|
162
150
|
|
163
|
-
def post_install
|
151
|
+
def post_install
|
164
152
|
return unless @post_install
|
165
153
|
|
166
154
|
post_install = render_post_install
|
@@ -173,7 +161,18 @@ module Hetzner
|
|
173
161
|
logger.info output
|
174
162
|
end
|
175
163
|
|
176
|
-
|
164
|
+
def post_install_remote
|
165
|
+
remote do |ssh|
|
166
|
+
@post_install_remote.split("\n").each do |cmd|
|
167
|
+
cmd.chomp!
|
168
|
+
logger.info "executing #{cmd}"
|
169
|
+
ssh.exec!(cmd)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
rescue IOError, Net::SSH::Disconnect
|
173
|
+
logger.debug 'SSH connection was closed.'
|
174
|
+
end
|
175
|
+
|
177
176
|
def render_template
|
178
177
|
eruby = Erubis::Eruby.new @template.to_s
|
179
178
|
|
@@ -181,7 +180,7 @@ module Hetzner
|
|
181
180
|
params[:hostname] = @hostname
|
182
181
|
params[:ip] = @ip
|
183
182
|
|
184
|
-
|
183
|
+
eruby.result(params)
|
185
184
|
end
|
186
185
|
|
187
186
|
def render_post_install
|
@@ -192,8 +191,8 @@ module Hetzner
|
|
192
191
|
params[:ip] = @ip
|
193
192
|
params[:login] = @login
|
194
193
|
params[:password] = @password
|
195
|
-
|
196
|
-
|
194
|
+
|
195
|
+
eruby.result(params)
|
197
196
|
end
|
198
197
|
|
199
198
|
def use_api(api_obj)
|
@@ -204,19 +203,16 @@ module Hetzner
|
|
204
203
|
@logger = logger_obj
|
205
204
|
@logger.formatter = default_log_formatter
|
206
205
|
end
|
207
|
-
|
208
|
-
def remote(options = {}, &block)
|
209
206
|
|
210
|
-
|
207
|
+
def remote(options = {}, &block)
|
208
|
+
default = { verify_host_key: :never, password: @password }
|
211
209
|
default.merge! options
|
212
|
-
|
213
|
-
Net::SSH.start(@ip, @login, default)
|
214
|
-
block.call ssh
|
215
|
-
end
|
210
|
+
|
211
|
+
Net::SSH.start(@ip, @login, default, &block)
|
216
212
|
end
|
217
213
|
|
218
|
-
def local
|
219
|
-
|
214
|
+
def local
|
215
|
+
yield
|
220
216
|
end
|
221
217
|
|
222
218
|
def reset_retries
|
@@ -224,19 +220,24 @@ module Hetzner
|
|
224
220
|
end
|
225
221
|
|
226
222
|
def rolling_sleep
|
227
|
-
|
223
|
+
# => 1, 4, 13, 28, 49, 76, 109, 148, 193, 244, 301, 364 ... seconds
|
224
|
+
sleep @retries * @retries * 3 + 1
|
228
225
|
end
|
229
226
|
|
230
227
|
def default_log_formatter
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
228
|
+
proc do |_severity, datetime, _progname, msg|
|
229
|
+
caller(5..5).first =~ /`(.*?)'/
|
230
|
+
"[#{datetime.strftime '%H:%M:%S'}][#{format '%-15s', ip}]" \
|
231
|
+
"[#{Regexp.last_match(1)}] #{msg}\n"
|
232
|
+
end
|
235
233
|
end
|
236
234
|
|
237
235
|
class NoTemplateProvidedError < ArgumentError; end
|
236
|
+
|
238
237
|
class CantActivateRescueSystemError < StandardError; end
|
238
|
+
|
239
239
|
class CantResetSystemError < StandardError; end
|
240
|
+
|
240
241
|
class InstallationError < StandardError; end
|
241
242
|
end
|
242
243
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Hetzner
|
2
4
|
class Bootstrap
|
3
5
|
class Template
|
@@ -12,9 +14,10 @@ module Hetzner
|
|
12
14
|
#
|
13
15
|
# also run: $ installimage -h
|
14
16
|
#
|
15
|
-
|
16
|
-
|
17
|
-
|
17
|
+
case param
|
18
|
+
when Hetzner::Bootstrap::Template
|
19
|
+
param
|
20
|
+
when String
|
18
21
|
@raw_template = param
|
19
22
|
end
|
20
23
|
end
|
@@ -24,4 +27,4 @@ module Hetzner
|
|
24
27
|
end
|
25
28
|
end
|
26
29
|
end
|
27
|
-
end
|
30
|
+
end
|
@@ -1,51 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'hetzner-api'
|
2
|
-
require '
|
4
|
+
require 'hetzner-bootstrap'
|
3
5
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
6
|
+
# rubocop:disable Metrics/BlockLength
|
7
|
+
describe 'Bootstrap' do
|
8
|
+
let(:bs) do
|
9
|
+
Hetzner::Bootstrap.new(api: Hetzner::API.new(API_USERNAME, API_PASSWORD))
|
8
10
|
end
|
9
11
|
|
10
|
-
context
|
11
|
-
|
12
|
-
|
13
|
-
@bootstrap.add_target proper_target
|
14
|
-
@bootstrap.targets.should have(1).target
|
15
|
-
@bootstrap.targets.first.should be_instance_of Hetzner::Bootstrap::Target
|
16
|
-
end
|
12
|
+
context 'add target' do
|
13
|
+
it 'should be able to add a server to operate on' do
|
14
|
+
bs.add_target proper_target
|
17
15
|
|
18
|
-
|
19
|
-
|
20
|
-
@bootstrap.targets.first.template.should be_instance_of Hetzner::Bootstrap::Template
|
16
|
+
expect(bs.targets.size).to be_eql(1)
|
17
|
+
expect(bs.targets.first).to be_instance_of(Hetzner::Bootstrap::Target)
|
21
18
|
end
|
22
19
|
|
23
|
-
it
|
24
|
-
|
25
|
-
|
26
|
-
|
20
|
+
it 'should have the default template if none is specified' do
|
21
|
+
bs.add_target proper_target
|
22
|
+
|
23
|
+
expect(bs.targets.first.template).to be_instance_of Hetzner::Bootstrap::Template
|
27
24
|
end
|
28
|
-
|
29
25
|
end
|
30
26
|
|
31
27
|
def proper_target
|
32
|
-
|
33
|
-
:
|
34
|
-
:
|
35
|
-
# :password => "halloMartin!",
|
36
|
-
:
|
37
|
-
:
|
38
|
-
:
|
28
|
+
{
|
29
|
+
ip: '1.2.3.4',
|
30
|
+
login: 'root',
|
31
|
+
# :password => "halloMartin!",
|
32
|
+
rescue_os: 'linux',
|
33
|
+
rescue_os_bit: '64',
|
34
|
+
template: default_template
|
39
35
|
}
|
40
36
|
end
|
41
37
|
|
42
38
|
def improper_target_without_template
|
43
|
-
proper_target.
|
39
|
+
proper_target.except(:template)
|
44
40
|
end
|
45
41
|
|
46
42
|
def default_template
|
47
|
-
|
43
|
+
'string'
|
48
44
|
end
|
49
45
|
end
|
50
|
-
|
51
|
-
|
46
|
+
# rubocop:enable Metrics/BlockLength
|