kitchen-linode 0.14.0 → 0.15.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +38 -0
- data/.gitignore +1 -0
- data/.kitchen.yml +68 -0
- data/.rubocop.yml +2 -1155
- data/Gemfile +3 -1
- data/README.md +75 -67
- data/Rakefile +14 -10
- data/kitchen-linode.gemspec +21 -17
- data/lib/kitchen/driver/linode.rb +236 -193
- data/lib/kitchen/driver/linode_version.rb +1 -2
- data/spec/kitchen/driver/linode_spec.rb +258 -94
- data/spec/mocks/create.txt +67 -0
- data/spec/mocks/create_bad.txt +26 -0
- data/spec/mocks/create_ratelimit.txt +26 -0
- data/spec/mocks/create_timeout.txt +25 -0
- data/spec/mocks/delete.txt +27 -0
- data/spec/mocks/list.txt +34 -0
- data/spec/mocks/view.txt +67 -0
- data/spec/spec_helper.rb +39 -2
- metadata +106 -25
- data/.tailor +0 -4
- data/.travis.yml +0 -11
@@ -1,4 +1,3 @@
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
2
1
|
#
|
3
2
|
# Author:: Brett Taylor (<btaylor@linode.com>)
|
4
3
|
#
|
@@ -16,9 +15,12 @@
|
|
16
15
|
# See the License for the specific language governing permissions and
|
17
16
|
# limitations under the License.
|
18
17
|
|
19
|
-
require
|
20
|
-
require
|
21
|
-
|
18
|
+
require "securerandom" unless defined?(SecureRandom)
|
19
|
+
require "kitchen"
|
20
|
+
require "fog/linode"
|
21
|
+
require "fog/json"
|
22
|
+
require "retryable" unless defined?(Retryable)
|
23
|
+
require_relative "linode_version"
|
22
24
|
|
23
25
|
module Kitchen
|
24
26
|
|
@@ -29,235 +31,276 @@ module Kitchen
|
|
29
31
|
class Linode < Kitchen::Driver::Base
|
30
32
|
kitchen_driver_api_version 2
|
31
33
|
plugin_version Kitchen::Driver::LINODE_VERSION
|
32
|
-
|
33
|
-
default_config :
|
34
|
-
|
35
|
-
|
36
|
-
default_config :
|
37
|
-
|
38
|
-
|
39
|
-
default_config :
|
40
|
-
default_config :
|
41
|
-
|
42
|
-
default_config :
|
43
|
-
default_config :
|
44
|
-
|
34
|
+
|
35
|
+
default_config :linode_token do
|
36
|
+
ENV["LINODE_TOKEN"]
|
37
|
+
end
|
38
|
+
default_config :password do
|
39
|
+
ENV["LINODE_PASSWORD"] || SecureRandom.uuid
|
40
|
+
end
|
41
|
+
default_config :label, nil
|
42
|
+
default_config :tags, ["kitchen"]
|
43
|
+
default_config :hostname, nil
|
44
|
+
default_config :image, nil
|
45
|
+
default_config :region do
|
46
|
+
ENV["LINODE_REGION"] || "us-east"
|
47
|
+
end
|
48
|
+
default_config :type, "g6-nanode-1"
|
49
|
+
default_config :stackscript_id, nil
|
50
|
+
default_config :stackscript_data, nil
|
51
|
+
default_config :swap_size, nil
|
52
|
+
default_config :private_ip, false
|
53
|
+
default_config :authorized_users do
|
54
|
+
ENV["LINODE_AUTH_USERS"].to_s.split(",")
|
55
|
+
end
|
45
56
|
default_config :private_key_path do
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
57
|
+
ENV["LINODE_PRIVATE_KEY"] || [
|
58
|
+
File.expand_path("~/.ssh/id_rsa"),
|
59
|
+
File.expand_path("~/.ssh/id_dsa"),
|
60
|
+
File.expand_path("~/.ssh/identity"),
|
61
|
+
File.expand_path("~/.ssh/id_ecdsa"),
|
62
|
+
].find { |path| File.exist?(path) }
|
50
63
|
end
|
64
|
+
expand_path_for :private_key_path
|
51
65
|
default_config :public_key_path do |driver|
|
52
|
-
driver[:private_key_path]
|
66
|
+
if driver[:private_key_path] && File.exist?(driver[:private_key_path] + ".pub")
|
67
|
+
driver[:private_key_path] + ".pub"
|
68
|
+
end
|
53
69
|
end
|
54
|
-
|
55
|
-
default_config :
|
56
|
-
|
57
|
-
required_config :api_key
|
58
|
-
required_config :private_key_path
|
59
|
-
required_config :public_key_path
|
70
|
+
expand_path_for :public_key_path
|
71
|
+
default_config :disable_ssh_password, true
|
72
|
+
default_config :api_retries, 5
|
60
73
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
74
|
+
required_config :linode_token
|
75
|
+
|
76
|
+
def initialize(config)
|
77
|
+
super
|
78
|
+
# configure to retry on timeouts and rate limits by default
|
79
|
+
Retryable.configure do |retry_config|
|
80
|
+
retry_config.log_method = method(:retry_log_method)
|
81
|
+
retry_config.exception_cb = method(:retry_exception_callback)
|
82
|
+
retry_config.on = [Excon::Error::Timeout,
|
83
|
+
Excon::Error::RequestTimeout,
|
84
|
+
Excon::Error::TooManyRequests]
|
85
|
+
retry_config.tries = config[:api_retries]
|
86
|
+
retry_config.sleep = lambda { |n| 2**n } # sleep 1, 2, 4, etc. each try
|
69
87
|
end
|
70
|
-
|
71
|
-
|
72
|
-
|
88
|
+
end
|
89
|
+
|
90
|
+
# create and boot server
|
91
|
+
def create(state)
|
92
|
+
return if state[:linode_id]
|
93
|
+
|
94
|
+
config_hostname
|
95
|
+
config_label
|
73
96
|
server = create_server
|
74
|
-
|
75
|
-
|
76
|
-
state[:linode_id]
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
setup_ssh(state) if bourne_shell?
|
97
|
+
|
98
|
+
update_state(state, server)
|
99
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> created."
|
100
|
+
info "Waiting for linode to boot..."
|
101
|
+
server.wait_for { server.status == "running" }
|
102
|
+
instance.transport.connection(state).wait_until_ready
|
103
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> ready."
|
104
|
+
setup_server(state) if bourne_shell?
|
83
105
|
rescue Fog::Errors::Error, Excon::Errors::Error => ex
|
106
|
+
error "Failed to create server: #{ex.class} - #{ex.message}"
|
84
107
|
raise ActionFailed, ex.message
|
85
108
|
end
|
86
109
|
|
87
110
|
def destroy(state)
|
88
111
|
return if state[:linode_id].nil?
|
89
|
-
server = compute.servers.get(state[:linode_id])
|
90
112
|
|
91
|
-
|
92
|
-
|
93
|
-
|
113
|
+
begin
|
114
|
+
Retryable.retryable do
|
115
|
+
server = compute.servers.get(state[:linode_id])
|
116
|
+
server.destroy
|
117
|
+
end
|
118
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> destroyed."
|
119
|
+
rescue Excon::Error::NotFound
|
120
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> not found."
|
121
|
+
end
|
94
122
|
state.delete(:linode_id)
|
95
|
-
state.delete(:
|
123
|
+
state.delete(:linode_label)
|
124
|
+
state.delete(:hostname)
|
125
|
+
state.delete(:ssh_key)
|
126
|
+
state.delete(:password)
|
96
127
|
end
|
97
|
-
|
128
|
+
|
98
129
|
private
|
99
|
-
|
130
|
+
|
100
131
|
def compute
|
101
|
-
Fog::Compute.new(:
|
132
|
+
Fog::Compute.new(provider: :linode, linode_token: config[:linode_token])
|
102
133
|
end
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
else
|
108
|
-
data_center = compute.data_centers.find { |dc| dc.location =~ /#{config[:data_center]}/ }
|
109
|
-
end
|
110
|
-
|
111
|
-
if data_center.nil?
|
112
|
-
fail(UserError, "No match for data_center: #{config[:data_center]}")
|
113
|
-
end
|
114
|
-
info "Got data center: #{data_center.location}..."
|
115
|
-
return data_center
|
134
|
+
|
135
|
+
# generate possible label suffixes
|
136
|
+
def suffixes
|
137
|
+
(0..999).to_a.sample(1000)
|
116
138
|
end
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
139
|
+
|
140
|
+
# generate a unique label
|
141
|
+
def generate_unique_label
|
142
|
+
# Try to generate a unique suffix and make sure nothing else on the account
|
143
|
+
# has the same label.
|
144
|
+
# The iterator is a randomized list from 0 to 999.
|
145
|
+
servers = compute.servers.all
|
146
|
+
suffixes.each do |suffix|
|
147
|
+
label = "#{config[:label]}_#{"%03d" % suffix}"
|
148
|
+
Retryable.retryable do
|
149
|
+
return label if servers.find { |server| server.label == label }.nil?
|
124
150
|
end
|
125
|
-
else
|
126
|
-
flavor = compute.flavors.find { |f| f.name =~ /#{config[:flavor]}/ }
|
127
|
-
end
|
128
|
-
|
129
|
-
if flavor.nil?
|
130
|
-
fail(UserError, "No match for flavor: #{config[:flavor]}")
|
131
151
|
end
|
132
|
-
|
133
|
-
|
152
|
+
# If we're here that means we couldn't make a unique label with the
|
153
|
+
# given prefix. Inform the user that they need to clean up their
|
154
|
+
# account.
|
155
|
+
error "Unable to generate a unique label with prefix #{config[:label]}."
|
156
|
+
error "Might need to cleanup your account."
|
157
|
+
raise(UserError, "Unable to generate a unique label.")
|
134
158
|
end
|
135
|
-
|
136
|
-
def
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
159
|
+
|
160
|
+
def create_server
|
161
|
+
# submit new linode request
|
162
|
+
Retryable.retryable(
|
163
|
+
on: [Excon::Error::BadRequest],
|
164
|
+
tries: config[:api_retries],
|
165
|
+
exception_cb: method(:create_server_exception_callback),
|
166
|
+
log_method: proc {}
|
167
|
+
) do
|
168
|
+
# This will retry if we get a response that the label must be
|
169
|
+
# unique. We wrap both of these in a retry so we generate a
|
170
|
+
# new label when we try again.
|
171
|
+
label = generate_unique_label
|
172
|
+
image = config[:image] || instance.platform.name
|
173
|
+
info "Creating Linode:"
|
174
|
+
info " label: #{label}"
|
175
|
+
info " region: #{config[:region]}"
|
176
|
+
info " image: #{image}"
|
177
|
+
info " type: #{config[:type]}"
|
178
|
+
info " tags: #{config[:tags]}"
|
179
|
+
info " swap_size: #{config[:swap_size]}" if config[:swap_size]
|
180
|
+
info " private_ip: #{config[:private_ip]}" if config[:private_ip]
|
181
|
+
info " stackscript_id: #{config[:stackscript_id]}" if config[:stackscript_id]
|
182
|
+
Retryable.retryable do
|
183
|
+
compute.servers.create(
|
184
|
+
label: label,
|
185
|
+
region: config[:region],
|
186
|
+
image: image,
|
187
|
+
type: config[:type],
|
188
|
+
tags: config[:tags],
|
189
|
+
stackscript_id: config[:stackscript_id],
|
190
|
+
stackscript_data: config[:stackscript_data],
|
191
|
+
swap_size: config[:swap_size],
|
192
|
+
private_ip: config[:private_ip],
|
193
|
+
root_pass: config[:password],
|
194
|
+
authorized_keys: config[:public_key_path] ? [open(config[:public_key_path]).read.strip] : [],
|
195
|
+
authorized_users: config[:authorized_users]
|
196
|
+
)
|
197
|
+
end
|
141
198
|
end
|
142
|
-
|
143
|
-
|
199
|
+
end
|
200
|
+
|
201
|
+
# post build server setup, including configuring the hostname
|
202
|
+
def setup_server(state)
|
203
|
+
info "Setting hostname..."
|
204
|
+
shortname = "#{config[:hostname].split(".")[0]}"
|
205
|
+
instance.transport.connection(state).execute(
|
206
|
+
"echo '127.0.0.1 #{config[:hostname]} #{shortname} localhost\n" +
|
207
|
+
"::1 #{config[:hostname]} #{shortname} localhost' > /etc/hosts && " +
|
208
|
+
"hostnamectl set-hostname #{config[:hostname]} &> /dev/null || " +
|
209
|
+
"hostname #{config[:hostname]} &> /dev/null"
|
210
|
+
)
|
211
|
+
if config[:private_key_path] && config[:public_key_path] && config[:disable_ssh_password]
|
212
|
+
# Disable password auth and bounce SSH
|
213
|
+
info "Disabling SSH password login..."
|
214
|
+
instance.transport.connection(state).execute(
|
215
|
+
"sed -ri 's/^#?PasswordAuthentication .*$/PasswordAuthentication no/' /etc/ssh/sshd_config &&" +
|
216
|
+
"systemctl restart ssh &> /dev/null || " + # Ubuntu, Debian, most systemd distros
|
217
|
+
"systemctl restart sshd &> /dev/null || " + # CentOS 7+
|
218
|
+
"/etc/init.d/sshd restart &> /dev/null || " + # OpenRC (Gentoo, Alpine) and sysvinit
|
219
|
+
"/etc/init.d/ssh restart &> /dev/null || " + # Other OpenRC and sysvinit distros
|
220
|
+
"/etc/rc.d/rc.sshd restart &> /dev/null && " + # Slackware
|
221
|
+
"sleep 1" # Sleep because Slackware's rc script doesn't start SSH back up without it
|
222
|
+
)
|
144
223
|
end
|
145
|
-
info "
|
146
|
-
return image
|
224
|
+
info "Done setting up server."
|
147
225
|
end
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
226
|
+
|
227
|
+
# generate a label prefix if none is supplied and ensure it's less than
|
228
|
+
# the character limit.
|
229
|
+
def config_label
|
230
|
+
unless config[:label]
|
231
|
+
basename = config[:kitchen_root] ? File.basename(config[:kitchen_root]) : "job"
|
232
|
+
jobname = ENV["JOB_NAME"] || ENV["GITHUB_JOB"] || basename
|
233
|
+
config[:label] = "kitchen-#{jobname}-#{instance.name}"
|
154
234
|
end
|
155
|
-
|
156
|
-
|
235
|
+
config[:label] = config[:label].tr(" /", "_")
|
236
|
+
# cut to fit Linode 64 character maximum
|
237
|
+
# we trim to 60 so we can add '_' with a random 3 digit suffix later
|
238
|
+
if config[:label].size >= 60
|
239
|
+
config[:label] = "#{config[:label][0..59]}"
|
157
240
|
end
|
158
|
-
info "Got kernel: #{kernel.name}..."
|
159
|
-
return kernel
|
160
|
-
end
|
161
|
-
|
162
|
-
def create_server
|
163
|
-
data_center = get_dc
|
164
|
-
flavor = get_flavor
|
165
|
-
image = get_image
|
166
|
-
kernel = get_kernel
|
167
|
-
|
168
|
-
# submit new linode request
|
169
|
-
compute.servers.create(
|
170
|
-
:data_center => data_center,
|
171
|
-
:flavor => flavor,
|
172
|
-
:payment_terms => config[:payment_terms],
|
173
|
-
:name => config[:server_name],
|
174
|
-
:image => image,
|
175
|
-
:kernel => kernel,
|
176
|
-
:username => config[:username],
|
177
|
-
:password => config[:password]
|
178
|
-
)
|
179
|
-
end
|
180
|
-
|
181
|
-
def setup_ssh(state)
|
182
|
-
set_ssh_keys
|
183
|
-
state[:ssh_key] = config[:private_key_path]
|
184
|
-
do_ssh_setup(state, config)
|
185
241
|
end
|
186
242
|
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
:password => config[:password],
|
193
|
-
:timeout => config[:ssh_timeout])
|
194
|
-
pub_key = open(config[:public_key_path]).read
|
195
|
-
shortname = "#{config[:vm_hostname].split('.')[0]}"
|
196
|
-
hostsfile = "127.0.0.1 #{config[:vm_hostname]} #{shortname} localhost\n::1 #{config[:vm_hostname]} #{shortname} localhost"
|
197
|
-
@max_interval = 60
|
198
|
-
@max_retries = 10
|
199
|
-
@retries = 0
|
200
|
-
begin
|
201
|
-
ssh.run([
|
202
|
-
%(echo "#{hostsfile}" > /etc/hosts),
|
203
|
-
%(hostnamectl set-hostname #{config[:vm_hostname]}),
|
204
|
-
%(mkdir .ssh),
|
205
|
-
%(echo "#{pub_key}" >> ~/.ssh/authorized_keys),
|
206
|
-
%(passwd -l #{config[:username]})
|
207
|
-
])
|
208
|
-
rescue
|
209
|
-
@retries ||= 0
|
210
|
-
if @retries < @max_retries
|
211
|
-
info "Retrying connection..."
|
212
|
-
sleep [2**(@retries - 1), @max_interval].min
|
213
|
-
@retries += 1
|
214
|
-
retry
|
243
|
+
# configure the hostname either by the provided label or the instance name
|
244
|
+
def config_hostname
|
245
|
+
if config[:hostname].nil?
|
246
|
+
if config[:label]
|
247
|
+
config[:hostname] = "#{config[:label]}"
|
215
248
|
else
|
216
|
-
|
249
|
+
config[:hostname] = "#{instance.name}"
|
217
250
|
end
|
218
251
|
end
|
219
|
-
info "Done setting up SSH access."
|
220
252
|
end
|
221
|
-
|
222
|
-
#
|
223
|
-
def
|
224
|
-
|
225
|
-
|
226
|
-
|
253
|
+
|
254
|
+
# update the kitchen state with the returned server
|
255
|
+
def update_state(state, server)
|
256
|
+
state[:linode_id] = server.id
|
257
|
+
state[:linode_label] = server.label
|
258
|
+
state[:hostname] = server.ipv4[0]
|
259
|
+
if config[:private_key_path] && config[:public_key_path]
|
260
|
+
state[:ssh_key] = config[:private_key_path]
|
227
261
|
else
|
228
|
-
|
229
|
-
|
230
|
-
# use jenkins job name variable. "kitchen_root" turns into "workspace" which is uninformative.
|
231
|
-
jobname = ENV["JOB_NAME"]
|
232
|
-
elsif ENV["TRAVIS_REPO_SLUG"]
|
233
|
-
jobname = ENV["TRAVIS_REPO_SLUG"]
|
234
|
-
else
|
235
|
-
jobname = File.basename(config[:kitchen_root])
|
236
|
-
end
|
237
|
-
config[:server_name] = "kitchen-#{jobname}-#{instance.name}-#{Time.now.to_i.to_s}".tr(" /", "_")
|
238
|
-
end
|
239
|
-
|
240
|
-
# cut to fit Linode 32 character maximum
|
241
|
-
if config[:server_name].is_a?(String) && config[:server_name].size >= 32
|
242
|
-
config[:server_name] = "#{config[:server_name][0..29]}#{rand(10..99)}"
|
262
|
+
warn "Using SSH password auth, some things may not work."
|
263
|
+
state[:password] = config[:password]
|
243
264
|
end
|
244
265
|
end
|
245
|
-
|
246
|
-
#
|
247
|
-
def
|
248
|
-
if
|
249
|
-
|
266
|
+
|
267
|
+
# retry exception callback to check if we need to wait for a rate limit
|
268
|
+
def retry_exception_callback(exception)
|
269
|
+
if exception.class == Excon::Error::TooManyRequests
|
270
|
+
# add a random value between 2 and 20 to the sleep to splay retries
|
271
|
+
sleep_time = exception.response.headers["Retry-After"].to_i + rand(2..20)
|
272
|
+
warn "Rate limit encountered, sleeping #{sleep_time} seconds for it to expire."
|
273
|
+
sleep(sleep_time)
|
250
274
|
end
|
251
275
|
end
|
252
|
-
|
253
|
-
#
|
254
|
-
def
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
276
|
+
|
277
|
+
# retry logging callback to print a message when we're retrying a request
|
278
|
+
def retry_log_method(retries, exception)
|
279
|
+
warn "[Attempt ##{retries}] Retrying because [#{exception.class}]"
|
280
|
+
end
|
281
|
+
|
282
|
+
# create_server callback to check if we can retry the request
|
283
|
+
def create_server_exception_callback(exception)
|
284
|
+
unless exception.response.body.include? "Label must be unique"
|
285
|
+
# not a retriable error.
|
286
|
+
# decode our error(s) and print for the user, then raise a UserError.
|
287
|
+
begin
|
288
|
+
resp_errors = Fog::JSON.decode(exception.response.body)["errors"]
|
289
|
+
resp_errors.each do |resp_error|
|
290
|
+
error "error:"
|
291
|
+
resp_error.each do |key, value|
|
292
|
+
error " #{key}: #{value}"
|
293
|
+
end
|
294
|
+
end
|
295
|
+
rescue
|
296
|
+
# something went wrong with decoding and pretty-printing the error
|
297
|
+
# just raise the original exception.
|
298
|
+
raise exception
|
299
|
+
end
|
300
|
+
raise(UserError, "Bad request when creating server.")
|
260
301
|
end
|
302
|
+
info "Got [#{exception.class}] due to non-unique label when creating server."
|
303
|
+
info "Will try again with a new label if we can."
|
261
304
|
end
|
262
305
|
end
|
263
306
|
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
2
1
|
#
|
3
2
|
# Author:: Brett Taylor (<btaylor@linode.com>)
|
4
3
|
#
|
@@ -20,6 +19,6 @@ module Kitchen
|
|
20
19
|
|
21
20
|
module Driver
|
22
21
|
# Version string for Linode Kitchen driver
|
23
|
-
LINODE_VERSION = "0.
|
22
|
+
LINODE_VERSION = "0.15.0".freeze
|
24
23
|
end
|
25
24
|
end
|